diff --git a/assets/icons/pencil.svg b/assets/icons/pencil.svg new file mode 100644 index 0000000000..76c5ceb347 --- /dev/null +++ b/assets/icons/pencil.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 42fdc0d78c..ccbb86db22 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -597,12 +597,6 @@ "tab": "channel_modal::ToggleMode" } }, - { - "context": "ChatPanel > MessageEditor", - "bindings": { - "escape": "chat_panel::CloseReplyPreview" - } - }, { "context": "Terminal", "bindings": { diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index bc26a8477b..9085cebfa3 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -51,6 +51,7 @@ pub struct ChannelMessage { pub nonce: u128, pub mentions: Vec<(Range, UserId)>, pub reply_to_message_id: Option, + pub edited_at: Option, } #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -83,6 +84,10 @@ pub enum ChannelChatEvent { old_range: Range, new_count: usize, }, + UpdateMessage { + message_id: ChannelMessageId, + message_ix: usize, + }, NewMessage { channel_id: ChannelId, message_id: u64, @@ -93,6 +98,7 @@ impl EventEmitter for ChannelChat {} pub fn init(client: &Arc) { client.add_model_message_handler(ChannelChat::handle_message_sent); client.add_model_message_handler(ChannelChat::handle_message_removed); + client.add_model_message_handler(ChannelChat::handle_message_updated); } impl ChannelChat { @@ -189,6 +195,7 @@ impl ChannelChat { mentions: message.mentions.clone(), nonce, reply_to_message_id: message.reply_to_message_id, + edited_at: None, }, &(), ), @@ -234,6 +241,35 @@ impl ChannelChat { }) } + pub fn update_message( + &mut self, + id: u64, + message: MessageParams, + cx: &mut ModelContext, + ) -> Result>> { + self.message_update( + ChannelMessageId::Saved(id), + message.text.clone(), + message.mentions.clone(), + Some(OffsetDateTime::now_utc()), + cx, + ); + + let nonce: u128 = self.rng.gen(); + + let request = self.rpc.request(proto::UpdateChannelMessage { + channel_id: self.channel_id.0, + message_id: id, + body: message.text, + nonce: Some(nonce.into()), + mentions: mentions_to_proto(&message.mentions), + }); + Ok(cx.spawn(move |_, _| async move { + request.await?; + Ok(()) + })) + } + pub fn load_more_messages(&mut self, cx: &mut ModelContext) -> Option>> { if self.loaded_all_messages { return None; @@ -523,6 +559,32 @@ impl ChannelChat { Ok(()) } + async fn handle_message_updated( + this: Model, + message: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; + let message = message + .payload + .message + .ok_or_else(|| anyhow!("empty message"))?; + + let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?; + + this.update(&mut cx, |this, cx| { + this.message_update( + message.id, + message.body, + message.mentions, + message.edited_at, + cx, + ) + })?; + Ok(()) + } + fn insert_messages(&mut self, messages: SumTree, cx: &mut ModelContext) { if let Some((first_message, last_message)) = messages.first().zip(messages.last()) { let nonces = messages @@ -599,6 +661,38 @@ impl ChannelChat { } } } + + fn message_update( + &mut self, + id: ChannelMessageId, + body: String, + mentions: Vec<(Range, u64)>, + edited_at: Option, + cx: &mut ModelContext, + ) { + let mut cursor = self.messages.cursor::(); + let mut messages = cursor.slice(&id, Bias::Left, &()); + let ix = messages.summary().count; + + if let Some(mut message_to_update) = cursor.item().cloned() { + message_to_update.body = body; + message_to_update.mentions = mentions; + message_to_update.edited_at = edited_at; + messages.push(message_to_update, &()); + cursor.next(&()); + } + + messages.append(cursor.suffix(&()), &()); + drop(cursor); + self.messages = messages; + + cx.emit(ChannelChatEvent::UpdateMessage { + message_ix: ix, + message_id: id, + }); + + cx.notify(); + } } async fn messages_from_proto( @@ -623,6 +717,15 @@ impl ChannelMessage { user_store.get_user(message.sender_id, cx) })? .await?; + + let edited_at = message.edited_at.and_then(|t| -> Option { + if let Ok(a) = OffsetDateTime::from_unix_timestamp(t as i64) { + return Some(a); + } + + None + }); + Ok(ChannelMessage { id: ChannelMessageId::Saved(message.id), body: message.body, @@ -641,6 +744,7 @@ impl ChannelMessage { .ok_or_else(|| anyhow!("nonce is required"))? .into(), reply_to_message_id: message.reply_to_message_id, + edited_at, }) } diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index fd76bcc301..cee747a4e9 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -186,6 +186,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { mentions: vec![], nonce: Some(1.into()), reply_to_message_id: None, + edited_at: None, }, proto::ChannelMessage { id: 11, @@ -195,6 +196,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { mentions: vec![], nonce: Some(2.into()), reply_to_message_id: None, + edited_at: None, }, ], done: false, @@ -243,6 +245,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { mentions: vec![], nonce: Some(3.into()), reply_to_message_id: None, + edited_at: None, }), }); @@ -297,6 +300,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { nonce: Some(4.into()), mentions: vec![], reply_to_message_id: None, + edited_at: None, }, proto::ChannelMessage { id: 9, @@ -306,6 +310,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { nonce: Some(5.into()), mentions: vec![], reply_to_message_id: None, + edited_at: None, }, ], }, diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index d51b88f668..b66767a640 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -219,6 +219,7 @@ CREATE TABLE IF NOT EXISTS "channel_messages" ( "sender_id" INTEGER NOT NULL REFERENCES users (id), "body" TEXT NOT NULL, "sent_at" TIMESTAMP, + "edited_at" TIMESTAMP, "nonce" BLOB NOT NULL, "reply_to_message_id" INTEGER DEFAULT NULL ); diff --git a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql b/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql new file mode 100644 index 0000000000..1d07b07de7 --- /dev/null +++ b/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql @@ -0,0 +1 @@ +ALTER TABLE channel_messages ADD edited_at TIMESTAMP DEFAULT NULL; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 776f2d1a0a..4cab73c79e 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -458,6 +458,14 @@ pub struct CreatedChannelMessage { pub notifications: NotificationBatch, } +pub struct UpdatedChannelMessage { + pub message_id: MessageId, + pub participant_connection_ids: Vec, + pub notifications: NotificationBatch, + pub reply_to_message_id: Option, + pub timestamp: PrimitiveDateTime, +} + #[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)] pub struct Invite { pub email_address: String, diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 663e1d0f83..f0db33b2da 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -162,6 +162,9 @@ impl Database { lower_half: nonce.1, }), reply_to_message_id: row.reply_to_message_id.map(|id| id.to_proto()), + edited_at: row + .edited_at + .map(|t| t.assume_utc().unix_timestamp() as u64), } }) .collect::>(); @@ -199,6 +202,31 @@ impl Database { Ok(messages) } + fn format_mentions_to_entities( + &self, + message_id: MessageId, + body: &str, + mentions: &[proto::ChatMention], + ) -> Result> { + Ok(mentions + .iter() + .filter_map(|mention| { + let range = mention.range.as_ref()?; + if !body.is_char_boundary(range.start as usize) + || !body.is_char_boundary(range.end as usize) + { + return None; + } + Some(channel_message_mention::ActiveModel { + message_id: ActiveValue::Set(message_id), + start_offset: ActiveValue::Set(range.start as i32), + end_offset: ActiveValue::Set(range.end as i32), + user_id: ActiveValue::Set(UserId::from_proto(mention.user_id)), + }) + }) + .collect::>()) + } + /// Creates a new channel message. #[allow(clippy::too_many_arguments)] pub async fn create_channel_message( @@ -249,6 +277,7 @@ impl Database { nonce: ActiveValue::Set(Uuid::from_u128(nonce)), id: ActiveValue::NotSet, reply_to_message_id: ActiveValue::Set(reply_to_message_id), + edited_at: ActiveValue::NotSet, }) .on_conflict( OnConflict::columns([ @@ -270,23 +299,7 @@ impl Database { let mentioned_user_ids = mentions.iter().map(|m| m.user_id).collect::>(); - let mentions = mentions - .iter() - .filter_map(|mention| { - let range = mention.range.as_ref()?; - if !body.is_char_boundary(range.start as usize) - || !body.is_char_boundary(range.end as usize) - { - return None; - } - Some(channel_message_mention::ActiveModel { - message_id: ActiveValue::Set(message_id), - start_offset: ActiveValue::Set(range.start as i32), - end_offset: ActiveValue::Set(range.end as i32), - user_id: ActiveValue::Set(UserId::from_proto(mention.user_id)), - }) - }) - .collect::>(); + let mentions = self.format_mentions_to_entities(message_id, body, mentions)?; if !mentions.is_empty() { channel_message_mention::Entity::insert_many(mentions) .exec(&*tx) @@ -522,4 +535,131 @@ impl Database { }) .await } + + /// Updates the channel message with the given ID, body and timestamp(edited_at). + pub async fn update_channel_message( + &self, + channel_id: ChannelId, + message_id: MessageId, + user_id: UserId, + body: &str, + mentions: &[proto::ChatMention], + edited_at: OffsetDateTime, + ) -> Result { + self.transaction(|tx| async move { + let channel = self.get_channel_internal(channel_id, &tx).await?; + self.check_user_is_channel_participant(&channel, user_id, &tx) + .await?; + + let mut rows = channel_chat_participant::Entity::find() + .filter(channel_chat_participant::Column::ChannelId.eq(channel_id)) + .stream(&*tx) + .await?; + + let mut is_participant = false; + let mut participant_connection_ids = Vec::new(); + let mut participant_user_ids = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + if row.user_id == user_id { + is_participant = true; + } + participant_user_ids.push(row.user_id); + participant_connection_ids.push(row.connection()); + } + drop(rows); + + if !is_participant { + Err(anyhow!("not a chat participant"))?; + } + + let channel_message = channel_message::Entity::find_by_id(message_id) + .filter(channel_message::Column::SenderId.eq(user_id)) + .one(&*tx) + .await?; + + let Some(channel_message) = channel_message else { + Err(anyhow!("Channel message not found"))? + }; + + let edited_at = edited_at.to_offset(time::UtcOffset::UTC); + let edited_at = time::PrimitiveDateTime::new(edited_at.date(), edited_at.time()); + + let updated_message = channel_message::ActiveModel { + body: ActiveValue::Set(body.to_string()), + edited_at: ActiveValue::Set(Some(edited_at)), + reply_to_message_id: ActiveValue::Unchanged(channel_message.reply_to_message_id), + id: ActiveValue::Unchanged(message_id), + channel_id: ActiveValue::Unchanged(channel_id), + sender_id: ActiveValue::Unchanged(user_id), + sent_at: ActiveValue::Unchanged(channel_message.sent_at), + nonce: ActiveValue::Unchanged(channel_message.nonce), + }; + + let result = channel_message::Entity::update_many() + .set(updated_message) + .filter(channel_message::Column::Id.eq(message_id)) + .filter(channel_message::Column::SenderId.eq(user_id)) + .exec(&*tx) + .await?; + if result.rows_affected == 0 { + return Err(anyhow!( + "Attempted to edit a message (id: {message_id}) which does not exist anymore." + ))?; + } + + // we have to fetch the old mentions, + // so we don't send a notification when the message has been edited that you are mentioned in + let old_mentions = channel_message_mention::Entity::find() + .filter(channel_message_mention::Column::MessageId.eq(message_id)) + .all(&*tx) + .await?; + + // remove all existing mentions + channel_message_mention::Entity::delete_many() + .filter(channel_message_mention::Column::MessageId.eq(message_id)) + .exec(&*tx) + .await?; + + let new_mentions = self.format_mentions_to_entities(message_id, body, mentions)?; + if !new_mentions.is_empty() { + // insert new mentions + channel_message_mention::Entity::insert_many(new_mentions) + .exec(&*tx) + .await?; + } + + let mut mentioned_user_ids = mentions.iter().map(|m| m.user_id).collect::>(); + // Filter out users that were mentioned before + for mention in old_mentions { + mentioned_user_ids.remove(&mention.user_id.to_proto()); + } + + let mut notifications = Vec::new(); + for mentioned_user in mentioned_user_ids { + notifications.extend( + self.create_notification( + UserId::from_proto(mentioned_user), + rpc::Notification::ChannelMessageMention { + message_id: message_id.to_proto(), + sender_id: user_id.to_proto(), + channel_id: channel_id.to_proto(), + }, + false, + &tx, + ) + .await?, + ); + } + + Ok(UpdatedChannelMessage { + message_id, + participant_connection_ids, + notifications, + reply_to_message_id: channel_message.reply_to_message_id, + timestamp: channel_message.sent_at, + }) + }) + .await + } } diff --git a/crates/collab/src/db/tables/channel_message.rs b/crates/collab/src/db/tables/channel_message.rs index b2493d2ead..2ec776f189 100644 --- a/crates/collab/src/db/tables/channel_message.rs +++ b/crates/collab/src/db/tables/channel_message.rs @@ -11,6 +11,7 @@ pub struct Model { pub sender_id: UserId, pub body: String, pub sent_at: PrimitiveDateTime, + pub edited_at: Option, pub nonce: Uuid, pub reply_to_message_id: Option, } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 959f3aef62..735e1d3c50 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -6,7 +6,7 @@ use crate::{ self, BufferId, Channel, ChannelId, ChannelRole, ChannelsForUser, CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId, NotificationId, Project, ProjectId, RemoveChannelMemberResult, ReplicaId, RespondToChannelInvite, RoomId, ServerId, - User, UserId, + UpdatedChannelMessage, User, UserId, }, executor::Executor, AppState, Error, RateLimit, RateLimiter, Result, @@ -283,6 +283,7 @@ impl Server { .add_message_handler(leave_channel_chat) .add_request_handler(send_channel_message) .add_request_handler(remove_channel_message) + .add_request_handler(update_channel_message) .add_request_handler(get_channel_messages) .add_request_handler(get_channel_messages_by_id) .add_request_handler(get_notifications) @@ -3191,6 +3192,7 @@ async fn send_channel_message( }, ) .await?; + let message = proto::ChannelMessage { sender_id: session.user_id.to_proto(), id: message_id.to_proto(), @@ -3199,6 +3201,7 @@ async fn send_channel_message( timestamp: timestamp.unix_timestamp() as u64, nonce: Some(nonce), reply_to_message_id: request.reply_to_message_id, + edited_at: None, }; broadcast( Some(session.connection_id), @@ -3261,6 +3264,71 @@ async fn remove_channel_message( Ok(()) } +async fn update_channel_message( + request: proto::UpdateChannelMessage, + response: Response, + session: Session, +) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + let message_id = MessageId::from_proto(request.message_id); + let updated_at = OffsetDateTime::now_utc(); + let UpdatedChannelMessage { + message_id, + participant_connection_ids, + notifications, + reply_to_message_id, + timestamp, + } = session + .db() + .await + .update_channel_message( + channel_id, + message_id, + session.user_id, + request.body.as_str(), + &request.mentions, + updated_at, + ) + .await?; + + let nonce = request + .nonce + .clone() + .ok_or_else(|| anyhow!("nonce can't be blank"))?; + + let message = proto::ChannelMessage { + sender_id: session.user_id.to_proto(), + id: message_id.to_proto(), + body: request.body.clone(), + mentions: request.mentions.clone(), + timestamp: timestamp.assume_utc().unix_timestamp() as u64, + nonce: Some(nonce), + reply_to_message_id: reply_to_message_id.map(|id| id.to_proto()), + edited_at: Some(updated_at.unix_timestamp() as u64), + }; + + response.send(proto::Ack {})?; + + let pool = &*session.connection_pool().await; + broadcast( + Some(session.connection_id), + participant_connection_ids, + |connection| { + session.peer.send( + connection, + proto::ChannelMessageUpdate { + channel_id: channel_id.to_proto(), + message: Some(message.clone()), + }, + ) + }, + ); + + send_notifications(pool, &session.peer, notifications); + + Ok(()) +} + /// Mark a channel message as read async fn acknowledge_channel_message( request: proto::AckChannelMessage, diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index 18462a0e24..b3242485bd 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -466,3 +466,136 @@ async fn test_chat_replies(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) ) }); } + +#[gpui::test] +async fn test_chat_editing(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( + MessageParams { + text: "Initial message".into(), + reply_to_message_id: None, + mentions: Vec::new(), + }, + cx, + ) + .unwrap() + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + + channel_chat_a + .update(cx_a, |c, cx| { + c.update_message( + msg_id, + MessageParams { + text: "Updated body".into(), + reply_to_message_id: None, + mentions: Vec::new(), + }, + cx, + ) + .unwrap() + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + channel_chat_a.update(cx_a, |channel_chat, _| { + let update_message = channel_chat.find_loaded_message(msg_id).unwrap(); + + assert_eq!(update_message.body, "Updated body"); + assert_eq!(update_message.mentions, Vec::new()); + }); + channel_chat_b.update(cx_b, |channel_chat, _| { + let update_message = channel_chat.find_loaded_message(msg_id).unwrap(); + + assert_eq!(update_message.body, "Updated body"); + assert_eq!(update_message.mentions, Vec::new()); + }); + + // test mentions are updated correctly + + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 1); + let entry = store.notification_at(0).unwrap(); + assert!(matches!( + entry.notification, + Notification::ChannelInvitation { .. } + ),); + }); + + channel_chat_a + .update(cx_a, |c, cx| { + c.update_message( + msg_id, + MessageParams { + text: "Updated body including a mention for @user_b".into(), + reply_to_message_id: None, + mentions: vec![(37..45, client_b.id())], + }, + cx, + ) + .unwrap() + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + channel_chat_a.update(cx_a, |channel_chat, _| { + assert_eq!( + channel_chat.find_loaded_message(msg_id).unwrap().body, + "Updated body including a mention for @user_b", + ) + }); + channel_chat_b.update(cx_b, |channel_chat, _| { + assert_eq!( + channel_chat.find_loaded_message(msg_id).unwrap().body, + "Updated body including a mention for @user_b", + ) + }); + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 2); + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ChannelMessageMention { + message_id: msg_id, + sender_id: client_a.id(), + channel_id: channel_id.0, + } + ); + }); +} diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 7329f423eb..ff78ccde9c 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -5,18 +5,18 @@ use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, C use client::{ChannelId, Client}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; -use editor::Editor; +use editor::{actions, Editor}; use gpui::{ actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, ClipboardItem, CursorStyle, DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontWeight, - ListOffset, ListScrollEvent, ListState, Model, Render, Subscription, Task, View, ViewContext, - VisualContext, WeakView, + HighlightStyle, ListOffset, ListScrollEvent, ListState, Model, Render, Stateful, Subscription, + Task, View, ViewContext, VisualContext, WeakView, }; use language::LanguageRegistry; use menu::Confirm; use message_editor::MessageEditor; use project::Fs; -use rich_text::RichText; +use rich_text::{Highlight, RichText}; use serde::{Deserialize, Serialize}; use settings::Settings; use std::{sync::Arc, time::Duration}; @@ -64,7 +64,6 @@ pub struct ChatPanel { open_context_menu: Option<(u64, Subscription)>, highlighted_message: Option<(u64, Task<()>)>, last_acknowledged_message_id: Option, - selected_message_to_reply_id: Option, } #[derive(Serialize, Deserialize)] @@ -72,7 +71,7 @@ struct SerializedChatPanel { width: Option, } -actions!(chat_panel, [ToggleFocus, CloseReplyPreview]); +actions!(chat_panel, [ToggleFocus]); impl ChatPanel { pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { @@ -129,7 +128,6 @@ impl ChatPanel { open_context_menu: None, highlighted_message: None, last_acknowledged_message_id: None, - selected_message_to_reply_id: None, }; if let Some(channel_id) = ActiveCall::global(cx) @@ -268,6 +266,13 @@ impl ChatPanel { self.acknowledge_last_message(cx); } } + ChannelChatEvent::UpdateMessage { + message_id, + message_ix, + } => { + self.message_list.splice(*message_ix..*message_ix + 1, 1); + self.markdown_data.remove(message_id); + } ChannelChatEvent::NewMessage { channel_id, message_id, @@ -349,6 +354,7 @@ impl ChatPanel { .px_0p5() .gap_x_1() .rounded_md() + .overflow_hidden() .hover(|style| style.bg(cx.theme().colors().element_background)) .child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted)) .child(Avatar::new(user_being_replied_to.avatar_uri.clone()).size(rems(0.7))) @@ -413,6 +419,7 @@ impl ChatPanel { let belongs_to_user = Some(message.sender.id) == self.client.user_id(); let can_delete_message = belongs_to_user || is_admin; + let can_edit_message = belongs_to_user; let element_id: ElementId = match message.id { ChannelMessageId::Saved(id) => ("saved-message", id).into(), @@ -449,6 +456,8 @@ impl ChatPanel { cx.theme().colors().panel_background }; + let reply_to_message_id = self.message_editor.read(cx).reply_to_message_id(); + v_flex() .w_full() .relative() @@ -462,7 +471,7 @@ impl ChatPanel { .overflow_hidden() .px_1p5() .py_0p5() - .when_some(self.selected_message_to_reply_id, |el, reply_id| { + .when_some(reply_to_message_id, |el, reply_id| { el.when_some(message_id, |el, message_id| { el.when(reply_id == message_id, |el| { el.bg(cx.theme().colors().element_selected) @@ -559,7 +568,7 @@ impl ChatPanel { }, ) .child( - self.render_popover_buttons(&cx, message_id, can_delete_message) + self.render_popover_buttons(&cx, message_id, can_delete_message, can_edit_message) .neg_mt_2p5(), ) } @@ -571,94 +580,122 @@ impl ChatPanel { } } + fn render_popover_button(&self, cx: &ViewContext, child: Stateful
) -> Div { + div() + .w_6() + .bg(cx.theme().colors().element_background) + .hover(|style| style.bg(cx.theme().colors().element_hover).rounded_md()) + .child(child) + } + fn render_popover_buttons( &self, cx: &ViewContext, message_id: Option, can_delete_message: bool, + can_edit_message: bool, ) -> Div { - div() + h_flex() .absolute() - .child( - div() - .absolute() - .right_8() - .w_6() - .rounded_tl_md() - .rounded_bl_md() - .border_l_1() - .border_t_1() - .border_b_1() - .border_color(cx.theme().colors().element_selected) - .bg(cx.theme().colors().element_background) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .when(!self.has_open_menu(message_id), |el| { - el.visible_on_hover("") - }) - .when_some(message_id, |el, message_id| { - el.child( + .right_2() + .overflow_hidden() + .rounded_md() + .border_color(cx.theme().colors().element_selected) + .border_1() + .when(!self.has_open_menu(message_id), |el| { + el.visible_on_hover("") + }) + .bg(cx.theme().colors().element_background) + .when_some(message_id, |el, message_id| { + el.child( + self.render_popover_button( + cx, + div() + .id("reply") + .child( + IconButton::new(("reply", message_id), IconName::ReplyArrowRight) + .on_click(cx.listener(move |this, _, cx| { + this.message_editor.update(cx, |editor, cx| { + editor.set_reply_to_message_id(message_id); + editor.focus_handle(cx).focus(cx); + }) + })), + ) + .tooltip(|cx| Tooltip::text("Reply", cx)), + ), + ) + }) + .when_some(message_id, |el, message_id| { + el.when(can_edit_message, |el| { + el.child( + self.render_popover_button( + cx, div() - .id("reply") + .id("edit") .child( - IconButton::new( - ("reply", message_id), - IconName::ReplyArrowLeft, - ) - .on_click(cx.listener( - move |this, _, cx| { - this.selected_message_to_reply_id = Some(message_id); + IconButton::new(("edit", message_id), IconName::Pencil) + .on_click(cx.listener(move |this, _, cx| { this.message_editor.update(cx, |editor, cx| { - editor.set_reply_to_message_id(message_id); - editor.focus_handle(cx).focus(cx); - }) - }, - )), - ) - .tooltip(|cx| Tooltip::text("Reply", cx)), - ) - }), - ) - .child( - div() - .absolute() - .right_2() - .w_6() - .rounded_tr_md() - .rounded_br_md() - .border_r_1() - .border_t_1() - .border_b_1() - .border_color(cx.theme().colors().element_selected) - .bg(cx.theme().colors().element_background) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .when(!self.has_open_menu(message_id), |el| { - el.visible_on_hover("") - }) - .when_some(message_id, |el, message_id| { - let this = cx.view().clone(); + let message = this + .active_chat() + .and_then(|active_chat| { + active_chat + .read(cx) + .find_loaded_message(message_id) + }) + .cloned(); - el.child( - div() - .id("more") - .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, - )) - }), + if let Some(message) = message { + let buffer = editor + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("message editor must be singleton"); + + buffer.update(cx, |buffer, cx| { + buffer.set_text(message.body.clone(), cx) + }); + + editor.set_edit_message_id(message_id); + editor.focus_handle(cx).focus(cx); + } + }) + })), ) - .tooltip(|cx| Tooltip::text("More", cx)), - ) - }), - ) + .tooltip(|cx| Tooltip::text("Edit", cx)), + ), + ) + }) + }) + .when_some(message_id, |el, message_id| { + let this = cx.view().clone(); + + el.child( + self.render_popover_button( + cx, + div() + .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, + )) + }), + ) + .id("more") + .tooltip(|cx| Tooltip::text("More", cx)), + ), + ) + }) } fn render_message_menu( @@ -670,18 +707,6 @@ impl ChatPanel { let menu = { ContextMenu::build(cx, move |menu, cx| { menu.entry( - "Reply to message", - None, - cx.handler_for(&this, move |this, cx| { - this.selected_message_to_reply_id = Some(message_id); - - this.message_editor.update(cx, |editor, cx| { - editor.set_reply_to_message_id(message_id); - editor.focus_handle(cx).focus(cx); - }) - }), - ) - .entry( "Copy message text", None, cx.handler_for(&this, move |this, cx| { @@ -693,7 +718,7 @@ impl ChatPanel { } }), ) - .when(can_delete_message, move |menu| { + .when(can_delete_message, |menu| { menu.entry( "Delete message", None, @@ -725,22 +750,52 @@ impl ChatPanel { }) .collect::>(); - rich_text::render_rich_text(message.body.clone(), &mentions, language_registry, None) + const MESSAGE_UPDATED: &str = " (edited)"; + + let mut body = message.body.clone(); + + if message.edited_at.is_some() { + body.push_str(MESSAGE_UPDATED); + } + + let mut rich_text = rich_text::render_rich_text(body, &mentions, language_registry, None); + + if message.edited_at.is_some() { + rich_text.highlights.push(( + message.body.len()..(message.body.len() + MESSAGE_UPDATED.len()), + Highlight::Highlight(HighlightStyle { + fade_out: Some(0.8), + ..Default::default() + }), + )); + } + rich_text } fn send(&mut self, _: &Confirm, cx: &mut ViewContext) { - self.selected_message_to_reply_id = None; - if let Some((chat, _)) = self.active_chat.as_ref() { let message = self .message_editor .update(cx, |editor, cx| editor.take_message(cx)); - if let Some(task) = chat - .update(cx, |chat, cx| chat.send_message(message, cx)) - .log_err() - { - task.detach(); + if let Some(id) = self.message_editor.read(cx).edit_message_id() { + self.message_editor.update(cx, |editor, _| { + editor.clear_edit_message_id(); + }); + + if let Some(task) = chat + .update(cx, |chat, cx| chat.update_message(id, message, cx)) + .log_err() + { + task.detach(); + } + } else { + if let Some(task) = chat + .update(cx, |chat, cx| chat.send_message(message, cx)) + .log_err() + { + task.detach(); + } } } } @@ -825,16 +880,39 @@ impl ChatPanel { }) } - fn close_reply_preview(&mut self, _: &CloseReplyPreview, cx: &mut ViewContext) { - self.selected_message_to_reply_id = None; + fn close_reply_preview(&mut self, cx: &mut ViewContext) { self.message_editor .update(cx, |editor, _| editor.clear_reply_to_message_id()); } + + fn cancel_edit_message(&mut self, cx: &mut ViewContext) { + self.message_editor.update(cx, |editor, cx| { + // only clear the editor input if we were editing a message + if editor.edit_message_id().is_none() { + return; + } + + editor.clear_edit_message_id(); + + let buffer = editor + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("message editor must be singleton"); + + buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); + }); + } } 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(); + let message_editor = self.message_editor.read(cx); + + let reply_to_message_id = message_editor.reply_to_message_id(); + let edit_message_id = message_editor.edit_message_id(); v_flex() .key_context("ChatPanel") @@ -890,13 +968,36 @@ impl Render for ChatPanel { ) } })) + .when(!self.is_scrolled_to_bottom, |el| { + el.child(div().border_t_1().border_color(cx.theme().colors().border)) + }) + .when_some(edit_message_id, |el, _| { + el.child( + h_flex() + .px_2() + .text_ui_xs() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child("Editing message") + .child( + IconButton::new("cancel-edit-message", IconName::Close) + .shape(ui::IconButtonShape::Square) + .tooltip(|cx| Tooltip::text("Cancel edit message", cx)) + .on_click(cx.listener(move |this, _, cx| { + this.cancel_edit_message(cx); + })), + ), + ) + }) .when_some(reply_to_message_id, |el, reply_to_message_id| { let reply_message = self .active_chat() .and_then(|active_chat| { - active_chat.read(cx).messages().iter().find(|message| { - message.id == ChannelMessageId::Saved(reply_to_message_id) - }) + active_chat + .read(cx) + .find_loaded_message(reply_to_message_id) }) .cloned(); @@ -932,13 +1033,9 @@ impl Render for ChatPanel { .child( IconButton::new("close-reply-preview", IconName::Close) .shape(ui::IconButtonShape::Square) - .tooltip(|cx| { - Tooltip::for_action("Close reply", &CloseReplyPreview, cx) - }) + .tooltip(|cx| Tooltip::text("Close reply", cx)) .on_click(cx.listener(move |this, _, cx| { - this.selected_message_to_reply_id = None; - - cx.dispatch_action(CloseReplyPreview.boxed_clone()) + this.close_reply_preview(cx); })), ), ) @@ -947,13 +1044,11 @@ impl Render for ChatPanel { .children( Some( h_flex() - .key_context("MessageEditor") - .on_action(cx.listener(ChatPanel::close_reply_preview)) - .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() + .on_action(cx.listener(|this, _: &actions::Cancel, cx| { + this.cancel_edit_message(cx); + this.close_reply_preview(cx); + })) .map(|el| el.child(self.message_editor.clone())), ) .filter(|_| self.active_chat.is_some()), @@ -1056,6 +1151,7 @@ mod tests { nonce: 5, mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)], reply_to_message_id: None, + edited_at: None, }; let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); @@ -1103,6 +1199,7 @@ mod tests { nonce: 5, mentions: Vec::new(), reply_to_message_id: None, + edited_at: None, }; let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); @@ -1143,6 +1240,7 @@ mod tests { nonce: 5, mentions: Vec::new(), reply_to_message_id: None, + edited_at: 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 cf77a77d47..fbdded1cba 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -37,6 +37,7 @@ pub struct MessageEditor { mentions_task: Option>, channel_id: Option, reply_to_message_id: Option, + edit_message_id: Option, } struct MessageEditorCompletionProvider(WeakView); @@ -131,6 +132,7 @@ impl MessageEditor { mentions: Vec::new(), mentions_task: None, reply_to_message_id: None, + edit_message_id: None, } } @@ -146,6 +148,18 @@ impl MessageEditor { self.reply_to_message_id = None; } + pub fn edit_message_id(&self) -> Option { + self.edit_message_id + } + + pub fn set_edit_message_id(&mut self, edit_message_id: u64) { + self.edit_message_id = Some(edit_message_id); + } + + pub fn clear_edit_message_id(&mut self) { + self.edit_message_id = None; + } + pub fn set_channel( &mut self, channel_id: ChannelId, diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 0d67d62d90..b69c7bbae7 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -203,7 +203,9 @@ message Envelope { CompleteWithLanguageModel complete_with_language_model = 166; LanguageModelResponse language_model_response = 167; CountTokensWithLanguageModel count_tokens_with_language_model = 168; - CountTokensResponse count_tokens_response = 169; // current max + CountTokensResponse count_tokens_response = 169; + UpdateChannelMessage update_channel_message = 170; + ChannelMessageUpdate channel_message_update = 171; // current max } reserved 158 to 161; @@ -1184,6 +1186,14 @@ message RemoveChannelMessage { uint64 message_id = 2; } +message UpdateChannelMessage { + uint64 channel_id = 1; + uint64 message_id = 2; + Nonce nonce = 4; + string body = 5; + repeated ChatMention mentions = 6; +} + message AckChannelMessage { uint64 channel_id = 1; uint64 message_id = 2; @@ -1198,6 +1208,11 @@ message ChannelMessageSent { ChannelMessage message = 2; } +message ChannelMessageUpdate { + uint64 channel_id = 1; + ChannelMessage message = 2; +} + message GetChannelMessages { uint64 channel_id = 1; uint64 before_message_id = 2; @@ -1229,6 +1244,7 @@ message ChannelMessage { Nonce nonce = 5; repeated ChatMention mentions = 6; optional uint64 reply_to_message_id = 7; + optional uint64 edited_at = 8; } message ChatMention { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index b25b01a798..429452d3e1 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -149,6 +149,7 @@ messages!( (CallCanceled, Foreground), (CancelCall, Foreground), (ChannelMessageSent, Foreground), + (ChannelMessageUpdate, Foreground), (CompleteWithLanguageModel, Background), (CopyProjectEntry, Foreground), (CountTokensWithLanguageModel, Background), @@ -244,6 +245,7 @@ messages!( (ReloadBuffersResponse, Foreground), (RemoveChannelMember, Foreground), (RemoveChannelMessage, Foreground), + (UpdateChannelMessage, Foreground), (RemoveContact, Foreground), (RemoveProjectCollaborator, Foreground), (RenameChannel, Foreground), @@ -358,6 +360,7 @@ request_messages!( (ReloadBuffers, ReloadBuffersResponse), (RemoveChannelMember, Ack), (RemoveChannelMessage, Ack), + (UpdateChannelMessage, Ack), (RemoveContact, Ack), (RenameChannel, RenameChannelResponse), (RenameProjectEntry, ProjectEntryResponse), @@ -442,7 +445,9 @@ entity_messages!( entity_messages!( {channel_id, Channel}, ChannelMessageSent, + ChannelMessageUpdate, RemoveChannelMessage, + UpdateChannelMessage, UpdateChannelBuffer, UpdateChannelBufferCollaborators, ); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 9a0326bdcd..e637c64b8c 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -93,6 +93,7 @@ pub enum IconName { Option, PageDown, PageUp, + Pencil, Play, Plus, Public, @@ -188,6 +189,7 @@ impl IconName { IconName::Option => "icons/option.svg", IconName::PageDown => "icons/page_down.svg", IconName::PageUp => "icons/page_up.svg", + IconName::Pencil => "icons/pencil.svg", IconName::Play => "icons/play.svg", IconName::Plus => "icons/plus.svg", IconName::Public => "icons/public.svg",