Channel chat: Add edit message (#9035)

**Summary**:
- Removed reply message from message_menu
- Made render_popover_buttons a bit more reusable
- Fixed issue that you can't close the reply/edit preview when you are
not focusing the message editor
- Notify only the new people that were mentioned inside the edited
message

**Follow up**
- Fix that we update the notification message for the people that we
mentioned already
- Fix that we remove the notification when a message gets deleted.
  - Fix last acknowledge message id is in correct now

**Todo**:
- [x] Add tests
- [x] Change new added bindings to the `Editor::Cancel` event.

Release Notes:

- Added editing of chat messages
([#6707](https://github.com/zed-industries/zed/issues/6707)).

<img width="239" alt="Screenshot 2024-03-09 at 11 55 23"
src="https://github.com/zed-industries/zed/assets/62463826/b0949f0d-0f8b-43e1-ac20-4c6d40ac41e1">
<img width="240" alt="Screenshot 2024-03-13 at 13 34 23"
src="https://github.com/zed-industries/zed/assets/62463826/d0636da2-c5aa-4fed-858e-4bebe5695ba7">

---------

Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Remco Smits 2024-03-20 02:49:04 +01:00 committed by GitHub
parent 5139aa3811
commit 3dadfe4787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 743 additions and 149 deletions

View file

@ -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<u64>,
selected_message_to_reply_id: Option<u64>,
}
#[derive(Serialize, Deserialize)]
@ -72,7 +71,7 @@ struct SerializedChatPanel {
width: Option<Pixels>,
}
actions!(chat_panel, [ToggleFocus, CloseReplyPreview]);
actions!(chat_panel, [ToggleFocus]);
impl ChatPanel {
pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
@ -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<Self>, child: Stateful<Div>) -> 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<Self>,
message_id: Option<u64>,
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::<Vec<_>>();
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>) {
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>) {
self.selected_message_to_reply_id = None;
fn close_reply_preview(&mut self, cx: &mut ViewContext<Self>) {
self.message_editor
.update(cx, |editor, _| editor.clear_reply_to_message_id());
}
fn cancel_edit_message(&mut self, cx: &mut ViewContext<Self>) {
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<Self>) -> 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);

View file

@ -37,6 +37,7 @@ pub struct MessageEditor {
mentions_task: Option<Task<()>>,
channel_id: Option<ChannelId>,
reply_to_message_id: Option<u64>,
edit_message_id: Option<u64>,
}
struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
@ -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<u64> {
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,