diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index e9353a14419adf..ad843470be35cc 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -6,11 +6,12 @@ use client::{ Client, Subscription, TypedEnvelope, UserId, }; use futures::lock::Mutex; -use gpui::{AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, +}; use rand::prelude::*; use std::{ collections::HashSet, - mem, ops::{ControlFlow, Range}, sync::Arc, }; @@ -26,6 +27,7 @@ pub struct ChannelChat { loaded_all_messages: bool, last_acknowledged_id: Option, next_pending_message_id: usize, + first_loaded_message_id: Option, user_store: Model, rpc: Arc, outgoing_messages_lock: Arc>, @@ -37,6 +39,7 @@ pub struct ChannelChat { pub struct MessageParams { pub text: String, pub mentions: Vec<(Range, UserId)>, + pub reply_to_message_id: Option, } #[derive(Clone, Debug)] @@ -47,6 +50,7 @@ pub struct ChannelMessage { pub sender: Arc, pub nonce: u128, pub mentions: Vec<(Range, UserId)>, + pub reply_to_message_id: Option, } #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -55,6 +59,15 @@ pub enum ChannelMessageId { Pending(usize), } +impl Into> for ChannelMessageId { + fn into(self) -> Option { + match self { + ChannelMessageId::Saved(id) => Some(id), + ChannelMessageId::Pending(_) => None, + } + } +} + #[derive(Clone, Debug, Default)] pub struct ChannelMessageSummary { max_id: ChannelMessageId, @@ -96,28 +109,35 @@ impl ChannelChat { let response = client .request(proto::JoinChannelChat { channel_id }) .await?; - let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?; - let loaded_all_messages = response.done; - Ok(cx.new_model(|cx| { + let handle = cx.new_model(|cx| { cx.on_release(Self::release).detach(); - let mut this = Self { + Self { channel_id: channel.id, - user_store, + user_store: user_store.clone(), channel_store, - rpc: client, + rpc: client.clone(), outgoing_messages_lock: Default::default(), messages: Default::default(), acknowledged_message_ids: Default::default(), - loaded_all_messages, + loaded_all_messages: false, next_pending_message_id: 0, last_acknowledged_id: None, rng: StdRng::from_entropy(), + first_loaded_message_id: None, _subscription: subscription.set_model(&cx.handle(), &mut cx.to_async()), - }; - this.insert_messages(messages, cx); - this - })?) + } + })?; + Self::handle_loaded_messages( + handle.downgrade(), + user_store, + client, + response.messages, + response.done, + &mut cx, + ) + .await?; + Ok(handle) } fn release(&mut self, _: &mut AppContext) { @@ -166,6 +186,7 @@ impl ChannelChat { timestamp: OffsetDateTime::now_utc(), mentions: message.mentions.clone(), nonce, + reply_to_message_id: message.reply_to_message_id, }, &(), ), @@ -183,6 +204,7 @@ impl ChannelChat { body: message.text, nonce: Some(nonce.into()), mentions: mentions_to_proto(&message.mentions), + reply_to_message_id: message.reply_to_message_id, }); let response = request.await?; drop(outgoing_message_guard); @@ -227,12 +249,16 @@ impl ChannelChat { before_message_id, }) .await?; - let loaded_all_messages = response.done; - let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?; - this.update(&mut cx, |this, cx| { - this.loaded_all_messages = loaded_all_messages; - this.insert_messages(messages, cx); - })?; + Self::handle_loaded_messages( + this, + user_store, + rpc, + response.messages, + response.done, + &mut cx, + ) + .await?; + anyhow::Ok(()) } .log_err() @@ -240,9 +266,14 @@ impl ChannelChat { } pub fn first_loaded_message_id(&mut self) -> Option { - self.messages.first().and_then(|message| match message.id { - ChannelMessageId::Saved(id) => Some(id), - ChannelMessageId::Pending(_) => None, + self.first_loaded_message_id + } + + /// Load a message by its id, if it's already stored locally. + pub fn find_loaded_message(&self, id: u64) -> Option<&ChannelMessage> { + self.messages.iter().find(|message| match message.id { + ChannelMessageId::Saved(message_id) => message_id == id, + ChannelMessageId::Pending(_) => false, }) } @@ -304,6 +335,66 @@ impl ChannelChat { } } + async fn handle_loaded_messages( + this: WeakModel, + user_store: Model, + rpc: Arc, + proto_messages: Vec, + loaded_all_messages: bool, + cx: &mut AsyncAppContext, + ) -> Result<()> { + let loaded_messages = messages_from_proto(proto_messages, &user_store, cx).await?; + + let first_loaded_message_id = loaded_messages.first().map(|m| m.id); + let loaded_message_ids = this.update(cx, |this, _| { + let mut loaded_message_ids: HashSet = HashSet::default(); + for message in loaded_messages.iter() { + if let Some(saved_message_id) = message.id.into() { + loaded_message_ids.insert(saved_message_id); + } + } + for message in this.messages.iter() { + if let Some(saved_message_id) = message.id.into() { + loaded_message_ids.insert(saved_message_id); + } + } + loaded_message_ids + })?; + + let missing_ancestors = loaded_messages + .iter() + .filter_map(|message| { + if let Some(ancestor_id) = message.reply_to_message_id { + if !loaded_message_ids.contains(&ancestor_id) { + return Some(ancestor_id); + } + } + None + }) + .collect::>(); + + let loaded_ancestors = if missing_ancestors.is_empty() { + None + } else { + let response = rpc + .request(proto::GetChannelMessagesById { + message_ids: missing_ancestors, + }) + .await?; + Some(messages_from_proto(response.messages, &user_store, cx).await?) + }; + this.update(cx, |this, cx| { + this.first_loaded_message_id = first_loaded_message_id.and_then(|msg_id| msg_id.into()); + this.loaded_all_messages = loaded_all_messages; + this.insert_messages(loaded_messages, cx); + if let Some(loaded_ancestors) = loaded_ancestors { + this.insert_messages(loaded_ancestors, cx); + } + })?; + + Ok(()) + } + pub fn rejoin(&mut self, cx: &mut ModelContext) { let user_store = self.user_store.clone(); let rpc = self.rpc.clone(); @@ -311,28 +402,17 @@ impl ChannelChat { cx.spawn(move |this, mut cx| { async move { let response = rpc.request(proto::JoinChannelChat { channel_id }).await?; - let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?; - let loaded_all_messages = response.done; - - let pending_messages = this.update(&mut cx, |this, cx| { - if let Some((first_new_message, last_old_message)) = - messages.first().zip(this.messages.last()) - { - if first_new_message.id > last_old_message.id { - let old_messages = mem::take(&mut this.messages); - cx.emit(ChannelChatEvent::MessagesUpdated { - old_range: 0..old_messages.summary().count, - new_count: 0, - }); - this.loaded_all_messages = loaded_all_messages; - } - } - - this.insert_messages(messages, cx); - if loaded_all_messages { - this.loaded_all_messages = loaded_all_messages; - } - + Self::handle_loaded_messages( + this.clone(), + user_store.clone(), + rpc.clone(), + response.messages, + response.done, + &mut cx, + ) + .await?; + + let pending_messages = this.update(&mut cx, |this, _| { this.pending_messages().cloned().collect::>() })?; @@ -342,6 +422,7 @@ impl ChannelChat { body: pending_message.body, mentions: mentions_to_proto(&pending_message.mentions), nonce: Some(pending_message.nonce.into()), + reply_to_message_id: pending_message.reply_to_message_id, }); let response = request.await?; let message = ChannelMessage::from_proto( @@ -553,6 +634,7 @@ impl ChannelMessage { .nonce .ok_or_else(|| anyhow!("nonce is required"))? .into(), + reply_to_message_id: message.reply_to_message_id, }) } @@ -642,6 +724,7 @@ impl<'a> From<&'a str> for MessageParams { Self { text: value.into(), mentions: Vec::new(), + reply_to_message_id: None, } } } diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 57b9d2071091ca..c668b7202275cd 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -184,6 +184,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { sender_id: 5, mentions: vec![], nonce: Some(1.into()), + reply_to_message_id: None, }, proto::ChannelMessage { id: 11, @@ -192,6 +193,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { sender_id: 6, mentions: vec![], nonce: Some(2.into()), + reply_to_message_id: None, }, ], done: false, @@ -239,6 +241,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { sender_id: 7, mentions: vec![], nonce: Some(3.into()), + reply_to_message_id: None, }), }); @@ -292,6 +295,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { sender_id: 5, nonce: Some(4.into()), mentions: vec![], + reply_to_message_id: None, }, proto::ChannelMessage { id: 9, @@ -300,6 +304,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { sender_id: 6, nonce: Some(5.into()), mentions: vec![], + reply_to_message_id: None, }, ], }, diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 04676106086d5c..7be5725b68f7cb 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -217,7 +217,8 @@ CREATE TABLE IF NOT EXISTS "channel_messages" ( "sender_id" INTEGER NOT NULL REFERENCES users (id), "body" TEXT NOT NULL, "sent_at" TIMESTAMP, - "nonce" BLOB NOT NULL + "nonce" BLOB NOT NULL, + "reply_to_message_id" INTEGER DEFAULT NULL ); CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce"); diff --git a/crates/collab/migrations/20240203113741_add_reply_to_message.sql b/crates/collab/migrations/20240203113741_add_reply_to_message.sql new file mode 100644 index 00000000000000..6f40b62822bb49 --- /dev/null +++ b/crates/collab/migrations/20240203113741_add_reply_to_message.sql @@ -0,0 +1 @@ +ALTER TABLE channel_messages ADD reply_to_message_id INTEGER DEFAULT NULL diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 9baa7162c50d49..38a828efa4c91e 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -161,6 +161,7 @@ impl Database { upper_half: nonce.0, lower_half: nonce.1, }), + reply_to_message_id: row.reply_to_message_id.map(|id| id.to_proto()), } }) .collect::>(); @@ -207,6 +208,7 @@ impl Database { mentions: &[proto::ChatMention], timestamp: OffsetDateTime, nonce: u128, + reply_to_message_id: Option, ) -> Result { self.transaction(|tx| async move { let channel = self.get_channel_internal(channel_id, &*tx).await?; @@ -245,6 +247,7 @@ impl Database { sent_at: ActiveValue::Set(timestamp), nonce: ActiveValue::Set(Uuid::from_u128(nonce)), id: ActiveValue::NotSet, + reply_to_message_id: ActiveValue::Set(reply_to_message_id), }) .on_conflict( OnConflict::columns([ diff --git a/crates/collab/src/db/tables/channel_message.rs b/crates/collab/src/db/tables/channel_message.rs index ff49c63ba71d67..b2493d2eadd7cc 100644 --- a/crates/collab/src/db/tables/channel_message.rs +++ b/crates/collab/src/db/tables/channel_message.rs @@ -12,6 +12,7 @@ pub struct Model { pub body: String, pub sent_at: PrimitiveDateTime, pub nonce: Uuid, + pub reply_to_message_id: Option, } impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests/message_tests.rs b/crates/collab/src/db/tests/message_tests.rs index e0467b08f3a55d..c785e3cb733066 100644 --- a/crates/collab/src/db/tests/message_tests.rs +++ b/crates/collab/src/db/tests/message_tests.rs @@ -32,6 +32,7 @@ async fn test_channel_message_retrieval(db: &Arc) { &[], OffsetDateTime::now_utc(), i, + None, ) .await .unwrap() @@ -106,6 +107,7 @@ async fn test_channel_message_nonces(db: &Arc) { &mentions_to_proto(&[(3..10, user_b.to_proto())]), OffsetDateTime::now_utc(), 100, + None, ) .await .unwrap() @@ -118,6 +120,7 @@ async fn test_channel_message_nonces(db: &Arc) { &mentions_to_proto(&[]), OffsetDateTime::now_utc(), 200, + None, ) .await .unwrap() @@ -130,6 +133,7 @@ async fn test_channel_message_nonces(db: &Arc) { &mentions_to_proto(&[(4..11, user_c.to_proto())]), OffsetDateTime::now_utc(), 100, + None, ) .await .unwrap() @@ -142,6 +146,7 @@ async fn test_channel_message_nonces(db: &Arc) { &mentions_to_proto(&[]), OffsetDateTime::now_utc(), 200, + None, ) .await .unwrap() @@ -157,6 +162,7 @@ async fn test_channel_message_nonces(db: &Arc) { &mentions_to_proto(&[(4..11, user_a.to_proto())]), OffsetDateTime::now_utc(), 100, + None, ) .await .unwrap() @@ -231,17 +237,41 @@ async fn test_unseen_channel_messages(db: &Arc) { .unwrap(); let _ = db - .create_channel_message(channel_1, user, "1_1", &[], OffsetDateTime::now_utc(), 1) + .create_channel_message( + channel_1, + user, + "1_1", + &[], + OffsetDateTime::now_utc(), + 1, + None, + ) .await .unwrap(); let _ = db - .create_channel_message(channel_1, user, "1_2", &[], OffsetDateTime::now_utc(), 2) + .create_channel_message( + channel_1, + user, + "1_2", + &[], + OffsetDateTime::now_utc(), + 2, + None, + ) .await .unwrap(); let third_message = db - .create_channel_message(channel_1, user, "1_3", &[], OffsetDateTime::now_utc(), 3) + .create_channel_message( + channel_1, + user, + "1_3", + &[], + OffsetDateTime::now_utc(), + 3, + None, + ) .await .unwrap() .message_id; @@ -251,7 +281,15 @@ async fn test_unseen_channel_messages(db: &Arc) { .unwrap(); let fourth_message = db - .create_channel_message(channel_2, user, "2_1", &[], OffsetDateTime::now_utc(), 4) + .create_channel_message( + channel_2, + user, + "2_1", + &[], + OffsetDateTime::now_utc(), + 4, + None, + ) .await .unwrap() .message_id; @@ -317,6 +355,7 @@ async fn test_channel_message_mentions(db: &Arc) { &mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]), OffsetDateTime::now_utc(), 1, + None, ) .await .unwrap(); @@ -327,6 +366,7 @@ async fn test_channel_message_mentions(db: &Arc) { &mentions_to_proto(&[(4..11, user_c.to_proto())]), OffsetDateTime::now_utc(), 2, + None, ) .await .unwrap(); @@ -337,6 +377,7 @@ async fn test_channel_message_mentions(db: &Arc) { &mentions_to_proto(&[]), OffsetDateTime::now_utc(), 3, + None, ) .await .unwrap(); @@ -347,6 +388,7 @@ async fn test_channel_message_mentions(db: &Arc) { &mentions_to_proto(&[(0..7, user_b.to_proto())]), OffsetDateTime::now_utc(), 4, + None, ) .await .unwrap(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c97c283b2fda64..2861449db0bb6d 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -3019,6 +3019,10 @@ async fn send_channel_message( &request.mentions, timestamp, nonce.clone().into(), + match request.reply_to_message_id { + Some(reply_to_message_id) => Some(MessageId::from_proto(reply_to_message_id)), + None => None, + }, ) .await?; let message = proto::ChannelMessage { @@ -3028,6 +3032,7 @@ async fn send_channel_message( mentions: request.mentions, timestamp: timestamp.unix_timestamp() as u64, nonce: Some(nonce), + reply_to_message_id: request.reply_to_message_id, }; broadcast( Some(session.connection_id), diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index 270ba04f7ece01..081ac8d19ad7e1 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -43,6 +43,7 @@ async fn test_basic_channel_messages( MessageParams { text: "hi @user_c!".into(), mentions: vec![(3..10, client_c.id())], + reply_to_message_id: None, }, cx, ) @@ -402,3 +403,66 @@ async fn test_channel_message_changes( assert!(b_has_messages); } + +#[gpui::test] +async fn test_chat_replies(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + // Client A sends a message, client B should see that there is a new message. + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + let channel_chat_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + let msg_id = channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); + + cx_a.run_until_parked(); + + let reply_id = channel_chat_b + .update(cx_b, |c, cx| { + c.send_message( + MessageParams { + text: "reply".into(), + reply_to_message_id: Some(msg_id), + mentions: Vec::new(), + }, + cx, + ) + .unwrap() + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + + channel_chat_a.update(cx_a, |channel_chat, _| { + assert_eq!( + channel_chat + .find_loaded_message(reply_id) + .unwrap() + .reply_to_message_id, + Some(msg_id), + ) + }); +} diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 0ee4805a74b082..9a751335fadc2d 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,16 +1,16 @@ use crate::{collab_panel, ChatPanelSettings}; use anyhow::Result; use call::{room, ActiveCall}; -use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; +use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, ChannelStore}; use client::Client; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ - actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, DismissEvent, - ElementId, EventEmitter, Fill, FocusHandle, FocusableView, FontWeight, ListOffset, - ListScrollEvent, ListState, Model, Render, Subscription, Task, View, ViewContext, - VisualContext, WeakView, + actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, CursorStyle, + DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, + HighlightStyle, ListOffset, ListScrollEvent, ListState, Model, Render, StyledText, + Subscription, Task, View, ViewContext, VisualContext, WeakView, }; use language::LanguageRegistry; use menu::Confirm; @@ -23,7 +23,7 @@ use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; use ui::{ popover_menu, prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label, - TabBar, + TabBar, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -62,6 +62,7 @@ pub struct ChatPanel { markdown_data: HashMap, focus_handle: FocusHandle, open_context_menu: Option<(u64, Subscription)>, + highlighted_message: Option<(u64, Task<()>)>, } #[derive(Serialize, Deserialize)] @@ -124,6 +125,7 @@ impl ChatPanel { markdown_data: Default::default(), focus_handle: cx.focus_handle(), open_context_menu: None, + highlighted_message: None, }; if let Some(channel_id) = ActiveCall::global(cx) @@ -236,6 +238,7 @@ impl ChatPanel { let channel_name = chat.channel(cx).map(|channel| channel.name.clone()); self.message_editor.update(cx, |editor, cx| { editor.set_channel(channel_id, channel_name, cx); + editor.clear_reply_to_message_id(); }); }; let subscription = cx.subscribe(&chat, Self::channel_did_change); @@ -285,6 +288,99 @@ impl ChatPanel { } } + fn render_replied_to_message( + &mut self, + message_id: Option, + reply_to_message: &ChannelMessage, + cx: &mut ViewContext, + ) -> impl IntoElement { + let body_element_id: ElementId = match message_id { + Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message", id).into(), + Some(ChannelMessageId::Pending(id)) => ("reply-to-pending-message", id).into(), // This should never happen + None => ("composing-reply").into(), + }; + + let message_element_id: ElementId = match message_id { + Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message-container", id).into(), + Some(ChannelMessageId::Pending(id)) => { + ("reply-to-pending-message-container", id).into() + } // This should never happen + None => ("composing-reply-container").into(), + }; + + let current_channel_id = self.channel_id(cx); + let reply_to_message_id = reply_to_message.id; + + let reply_to_message_body = self + .markdown_data + .entry(reply_to_message.id) + .or_insert_with(|| { + Self::render_markdown_with_mentions( + &self.languages, + self.client.id(), + reply_to_message, + ) + }); + + const REPLY_TO_PREFIX: &str = "Reply to @"; + + div().flex_grow().child( + v_flex() + .id(message_element_id) + .text_ui_xs() + .child( + h_flex() + .gap_x_1() + .items_center() + .justify_start() + .overflow_x_hidden() + .whitespace_nowrap() + .child( + StyledText::new(format!( + "{}{}", + REPLY_TO_PREFIX, + reply_to_message.sender.github_login.clone() + )) + .with_highlights( + &cx.text_style(), + vec![( + (REPLY_TO_PREFIX.len() - 1) + ..(reply_to_message.sender.github_login.len() + + REPLY_TO_PREFIX.len()), + HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..Default::default() + }, + )], + ), + ), + ) + .child( + div() + .border_l_2() + .border_color(cx.theme().colors().border) + .px_1() + .py_0p5() + .mb_1() + .overflow_hidden() + .child( + div() + .max_h_12() + .child(reply_to_message_body.element(body_element_id, cx)), + ), + ) + .cursor(CursorStyle::PointingHand) + .tooltip(|cx| Tooltip::text("Go to message", cx)) + .on_click(cx.listener(move |chat_panel, _, cx| { + if let Some(channel_id) = current_channel_id { + chat_panel + .select_channel(channel_id, reply_to_message_id.into(), cx) + .detach_and_log_err(cx) + } + })), + ) + } + fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> impl IntoElement { let active_chat = &self.active_chat.as_ref().unwrap().0; let (message, is_continuation_from_previous, is_admin) = @@ -317,18 +413,9 @@ impl ChatPanel { }); let _is_pending = message.is_pending(); - let text = self.markdown_data.entry(message.id).or_insert_with(|| { - Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message) - }); let belongs_to_user = Some(message.sender.id) == self.client.user_id(); - let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) = - (message.id, belongs_to_user || is_admin) - { - Some(id) - } else { - None - }; + let can_delete_message = belongs_to_user || is_admin; let element_id: ElementId = match message.id { ChannelMessageId::Saved(id) => ("saved-message", id).into(), @@ -341,19 +428,41 @@ impl ChatPanel { .iter() .any(|m| Some(m.1) == self.client.user_id()); + let message_id = match message.id { + ChannelMessageId::Saved(id) => Some(id), + ChannelMessageId::Pending(_) => None, + }; + + let reply_to_message = message + .reply_to_message_id + .map(|id| active_chat.read(cx).find_loaded_message(id)) + .flatten() + .cloned(); + + let replied_to_you = + reply_to_message.as_ref().map(|m| m.sender.id) == self.client.user_id(); + + let is_highlighted_message = self + .highlighted_message + .as_ref() + .is_some_and(|(id, _)| Some(id) == message_id.as_ref()); + let background = if is_highlighted_message { + cx.theme().status().info_background + } else if mentioning_you || replied_to_you { + cx.theme().colors().background + } else { + cx.theme().colors().panel_background + }; + v_flex().w_full().relative().child( div() - .bg(if mentioning_you { - Fill::from(cx.theme().colors().background) - } else { - Fill::default() - }) + .bg(background) .rounded_md() .overflow_hidden() .px_1() .py_0p5() .when(!is_continuation_from_previous, |this| { - this.mt_1().child( + this.mt_2().child( h_flex() .text_ui_sm() .child(div().absolute().child( @@ -377,36 +486,86 @@ impl ChatPanel { ), ) }) - .when(mentioning_you, |this| this.mt_1()) - .child( - v_flex() - .w_full() - .text_ui_sm() - .id(element_id) - .group("") - .child(text.element("body".into(), cx)) - .child( + .when( + message.reply_to_message_id.is_some() && reply_to_message.is_none(), + |this| { + const MESSAGE_DELETED: &str = "Message has been deleted"; + + let body_text = StyledText::new(MESSAGE_DELETED).with_highlights( + &cx.text_style(), + vec![( + 0..MESSAGE_DELETED.len(), + HighlightStyle { + font_style: Some(FontStyle::Italic), + ..Default::default() + }, + )], + ); + + this.child( div() - .absolute() - .z_index(1) - .right_0() - .w_6() - .bg(cx.theme().colors().panel_background) - .when(!self.has_open_menu(message_id_to_remove), |el| { - el.visible_on_hover("") - }) - .children(message_id_to_remove.map(|message_id| { - popover_menu(("menu", message_id)) - .trigger(IconButton::new( - ("trigger", message_id), - IconName::Ellipsis, - )) - .menu(move |cx| { - Some(Self::render_message_menu(&this, message_id, cx)) - }) - })), - ), - ), + .border_l_2() + .text_ui_xs() + .border_color(cx.theme().colors().border) + .px_1() + .py_0p5() + .child(body_text), + ) + }, + ) + .when_some(reply_to_message, |el, reply_to_message| { + el.child(self.render_replied_to_message( + Some(message.id), + &reply_to_message, + cx, + )) + }) + .when(mentioning_you || replied_to_you, |this| this.my_0p5()) + .map(|el| { + let text = self.markdown_data.entry(message.id).or_insert_with(|| { + Self::render_markdown_with_mentions( + &self.languages, + self.client.id(), + &message, + ) + }); + el.child( + v_flex() + .w_full() + .text_ui_sm() + .id(element_id) + .group("") + .child(text.element("body".into(), cx)) + .child( + div() + .absolute() + .z_index(1) + .right_0() + .w_6() + .bg(background) + .when(!self.has_open_menu(message_id), |el| { + el.visible_on_hover("") + }) + .when_some(message_id, |el, message_id| { + el.child( + popover_menu(("menu", message_id)) + .trigger(IconButton::new( + ("trigger", message_id), + IconName::Ellipsis, + )) + .menu(move |cx| { + Some(Self::render_message_menu( + &this, + message_id, + can_delete_message, + cx, + )) + }), + ) + }), + ), + ) + }), ) } @@ -420,13 +579,27 @@ impl ChatPanel { fn render_message_menu( this: &View, message_id: u64, + can_delete_message: bool, cx: &mut WindowContext, ) -> View { let menu = { - let this = this.clone(); - ContextMenu::build(cx, move |menu, _| { - menu.entry("Delete message", None, move |cx| { - this.update(cx, |this, cx| this.remove_message(message_id, cx)) + ContextMenu::build(cx, move |menu, cx| { + menu.entry( + "Reply to message", + None, + cx.handler_for(&this, move |this, cx| { + this.message_editor.update(cx, |editor, cx| { + editor.set_reply_to_message_id(message_id); + editor.focus_handle(cx).focus(cx); + }) + }), + ) + .when(can_delete_message, move |menu| { + menu.entry( + "Delete message", + None, + cx.handler_for(&this, move |this, cx| this.remove_message(message_id, cx)), + ) }) }) }; @@ -517,7 +690,21 @@ impl ChatPanel { ChannelChat::load_history_since_message(chat.clone(), message_id, (*cx).clone()) .await { + let task = cx.spawn({ + let this = this.clone(); + + |mut cx| async move { + cx.background_executor().timer(Duration::from_secs(2)).await; + this.update(&mut cx, |this, cx| { + this.highlighted_message.take(); + cx.notify(); + }) + .ok(); + } + }); + this.update(&mut cx, |this, cx| { + this.highlighted_message = Some((message_id, task)); if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) { this.message_list.scroll_to(ListOffset { item_ix, @@ -536,6 +723,8 @@ impl ChatPanel { impl Render for ChatPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let reply_to_message_id = self.message_editor.read(cx).reply_to_message_id(); + v_flex() .track_focus(&self.focus_handle) .full() @@ -558,7 +747,7 @@ impl Render for ChatPanel { ), ), ) - .child(div().flex_grow().px_2().pt_1().map(|this| { + .child(div().flex_grow().px_2().map(|this| { if self.active_chat.is_some() { this.child(list(self.message_list.clone()).full()) } else { @@ -589,14 +778,56 @@ impl Render for ChatPanel { ) } })) + .when_some(reply_to_message_id, |el, reply_to_message_id| { + let reply_message = self + .active_chat() + .map(|active_chat| { + active_chat.read(cx).messages().iter().find_map(|m| { + if m.id == ChannelMessageId::Saved(reply_to_message_id) { + Some(m) + } else { + None + } + }) + }) + .flatten() + .cloned(); + + el.when_some(reply_message, |el, reply_message| { + el.child( + div() + .when(!self.is_scrolled_to_bottom, |el| { + el.border_t_1().border_color(cx.theme().colors().border) + }) + .flex() + .w_full() + .items_start() + .overflow_hidden() + .py_1() + .px_2() + .bg(cx.theme().colors().background) + .child(self.render_replied_to_message(None, &reply_message, cx)) + .child( + IconButton::new("close-reply-preview", IconName::Close) + .shape(ui::IconButtonShape::Square) + .on_click(cx.listener(move |this, _, cx| { + this.message_editor.update(cx, |editor, _| { + editor.clear_reply_to_message_id() + }); + })), + ), + ) + }) + }) .children( Some( h_flex() - .when(!self.is_scrolled_to_bottom, |el| { - el.border_t_1().border_color(cx.theme().colors().border) - }) + .when( + !self.is_scrolled_to_bottom && reply_to_message_id.is_none(), + |el| el.border_t_1().border_color(cx.theme().colors().border), + ) .p_2() - .child(self.message_editor.clone()), + .map(|el| el.child(self.message_editor.clone())), ) .filter(|_| self.active_chat.is_some()), ) @@ -738,6 +969,7 @@ mod tests { }), nonce: 5, mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)], + reply_to_message_id: None, }; let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 06501fe3fc22d6..d29929e1a21bea 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -34,6 +34,7 @@ pub struct MessageEditor { mentions: Vec, mentions_task: Option>, channel_id: Option, + reply_to_message_id: Option, } struct MessageEditorCompletionProvider(WeakView); @@ -112,9 +113,22 @@ impl MessageEditor { channel_id: None, mentions: Vec::new(), mentions_task: None, + reply_to_message_id: None, } } + pub fn reply_to_message_id(&self) -> Option { + self.reply_to_message_id + } + + pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) { + self.reply_to_message_id = Some(reply_to_message_id); + } + + pub fn clear_reply_to_message_id(&mut self) { + self.reply_to_message_id = None; + } + pub fn set_channel( &mut self, channel_id: u64, @@ -172,8 +186,13 @@ impl MessageEditor { editor.clear(cx); self.mentions.clear(); + let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id); - MessageParams { text, mentions } + MessageParams { + text, + mentions, + reply_to_message_id, + } }) } @@ -424,6 +443,7 @@ mod tests { MessageParams { text, mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)], + reply_to_message_id: None } ); }); diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 3a513902e5a5cd..1823b9b6c58d06 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1122,6 +1122,7 @@ message SendChannelMessage { string body = 2; Nonce nonce = 3; repeated ChatMention mentions = 4; + optional uint64 reply_to_message_id = 5; } message RemoveChannelMessage { @@ -1173,6 +1174,7 @@ message ChannelMessage { uint64 sender_id = 4; Nonce nonce = 5; repeated ChatMention mentions = 6; + optional uint64 reply_to_message_id = 7; } message ChatMention {