Rework/redesign message replies (#9049)

Evren Sen and Thorsten Ball created

Hello! This PR proposes a redesigned replying system in Zeds chat panel,
inspired by chat applications like [Slack](https://slack.com) and
[Discord](https://discord.com). Feedback and suggestions are welcome! 😄

### TODOs

- [x] Handle replies to removed messages
- [x] Add replied user's profile picture to reply indicator
- [x] Highlight the message that's been selected for replying

--------

### Current Status


https://github.com/zed-industries/zed/assets/146845123/4ed2c2d7-a586-48bd-973c-0d3f033e2c6b

--------

Release Notes:

- Redesigned message replies in the chat panel

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>

Change summary

assets/icons/reply_arrow_left.svg  |   0 
assets/icons/reply_arrow_right.svg |  56 +++++++
crates/collab_ui/src/chat_panel.rs | 245 +++++++++++++++----------------
crates/ui/src/components/icon.rs   |   6 
4 files changed, 182 insertions(+), 125 deletions(-)

Detailed changes

assets/icons/reply_arrow_right.svg 🔗

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
+
+<svg
+   width="800px"
+   height="800px"
+   viewBox="0 0 24 24"
+   fill="none"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="reply-svgrepo-com.svg"
+   inkscape:version="1.3.2 (091e20e, 2023-11-25)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <sodipodi:namedview
+     id="namedview1"
+     pagecolor="#505050"
+     bordercolor="#ffffff"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="1"
+     inkscape:deskcolor="#505050"
+     showgrid="false"
+     inkscape:zoom="0.39996789"
+     inkscape:cx="435.03492"
+     inkscape:cy="417.53351"
+     inkscape:window-width="1440"
+     inkscape:window-height="847"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg1" />
+  <g
+     id="SVGRepo_bgCarrier"
+     stroke-width="0" />
+  <g
+     id="SVGRepo_tracerCarrier"
+     stroke-linecap="round"
+     stroke-linejoin="round" />
+  <g
+     id="SVGRepo_iconCarrier"
+     transform="matrix(-1,0,0,1,24.001548,0)">
+    <path
+       d="M 20,17 V 15.8 C 20,14.1198 20,13.2798 19.673,12.638 19.3854,12.0735 18.9265,11.6146 18.362,11.327 17.7202,11 16.8802,11 15.2,11 H 4 m 0,0 4,-4 m -4,4 4,4"
+       stroke="#000000"
+       stroke-width="2"
+       stroke-linecap="round"
+       stroke-linejoin="round"
+       id="path1" />
+  </g>
+</svg>

crates/collab_ui/src/chat_panel.rs 🔗

@@ -8,9 +8,9 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use gpui::{
     actions, div, list, prelude::*, px, Action, AppContext, AsyncWindowContext, ClipboardItem,
-    CursorStyle, DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontStyle,
-    FontWeight, HighlightStyle, ListOffset, ListScrollEvent, ListState, Model, Render, StyledText,
-    Subscription, Task, View, ViewContext, VisualContext, WeakView,
+    CursorStyle, DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontWeight,
+    ListOffset, ListScrollEvent, ListState, Model, Render, Subscription, Task, View, ViewContext,
+    VisualContext, WeakView,
 };
 use language::LanguageRegistry;
 use menu::Confirm;
@@ -64,6 +64,7 @@ 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)]
@@ -128,6 +129,7 @@ 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)
@@ -300,15 +302,34 @@ impl ChatPanel {
     fn render_replied_to_message(
         &mut self,
         message_id: Option<ChannelMessageId>,
-        reply_to_message: &ChannelMessage,
+        reply_to_message: &Option<ChannelMessage>,
         cx: &mut ViewContext<Self>,
     ) -> 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 reply_to_message = match reply_to_message {
+            None => {
+                return div().child(
+                    h_flex()
+                        .text_ui_xs()
+                        .my_0p5()
+                        .px_0p5()
+                        .gap_x_1()
+                        .rounded_md()
+                        .child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted))
+                        .when(reply_to_message.is_none(), |el| {
+                            el.child(
+                                Label::new("Message has been deleted...")
+                                    .size(LabelSize::XSmall)
+                                    .color(Color::Muted),
+                            )
+                        }),
+                )
+            }
+            Some(val) => val,
         };
 
+        let user_being_replied_to = reply_to_message.sender.clone();
+        let message_being_replied_to = reply_to_message.clone();
+
         let message_element_id: ElementId = match message_id {
             Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message-container", id).into(),
             Some(ChannelMessageId::Pending(id)) => {
@@ -320,63 +341,30 @@ impl ChatPanel {
         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()
+        div().child(
+            h_flex()
                 .id(message_element_id)
                 .text_ui_xs()
+                .my_0p5()
+                .px_0p5()
+                .gap_x_1()
+                .rounded_md()
+                .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)))
                 .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()
-                                    },
-                                )],
-                            ),
-                        ),
+                    div().font_weight(FontWeight::SEMIBOLD).child(
+                        Label::new(format!("@{}", user_being_replied_to.github_login))
+                            .size(LabelSize::XSmall)
+                            .color(Color::Muted),
+                    ),
                 )
                 .child(
-                    div()
-                        .border_l_2()
-                        .border_color(cx.theme().colors().border)
-                        .px_1()
-                        .py_0p5()
-                        .mb_1()
-                        .child(
-                            div()
-                                .overflow_hidden()
-                                .max_h_12()
-                                .child(reply_to_message_body.element(body_element_id, cx)),
-                        ),
+                    div().overflow_y_hidden().child(
+                        Label::new(message_being_replied_to.body.replace('\n', " "))
+                            .size(LabelSize::XSmall)
+                            .color(Color::Default),
+                    ),
                 )
                 .cursor(CursorStyle::PointingHand)
                 .tooltip(|cx| Tooltip::text("Go to message", cx))
@@ -474,69 +462,59 @@ impl ChatPanel {
                     .overflow_hidden()
                     .px_1p5()
                     .py_0p5()
+                    .when_some(self.selected_message_to_reply_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)
+                            })
+                        })
+                    })
                     .when(!self.has_open_menu(message_id), |this| {
                         this.hover(|style| style.bg(cx.theme().colors().element_hover))
                     })
-                    .when(!is_continuation_from_previous, |this| {
-                        this.child(
-                            h_flex()
-                                .text_ui_sm()
-                                .child(div().absolute().child(
-                                    Avatar::new(message.sender.avatar_uri.clone()).size(rems(1.)),
-                                ))
-                                .child(
-                                    div()
-                                        .pl(cx.rem_size() + px(6.0))
-                                        .pr(px(8.0))
-                                        .font_weight(FontWeight::BOLD)
-                                        .child(Label::new(message.sender.github_login.clone())),
-                                )
-                                .child(
-                                    Label::new(time_format::format_localized_timestamp(
-                                        message.timestamp,
-                                        OffsetDateTime::now_utc(),
-                                        self.local_timezone,
-                                        time_format::TimestampFormat::EnhancedAbsolute,
-                                    ))
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                ),
-                        )
+                    .when(message.reply_to_message_id.is_some(), |el| {
+                        el.child(self.render_replied_to_message(
+                            Some(message.id),
+                            &reply_to_message,
+                            cx,
+                        ))
+                        .when(is_continuation_from_previous, |this| this.mt_2())
                     })
                     .when(
-                        message.reply_to_message_id.is_some() && reply_to_message.is_none(),
+                        !is_continuation_from_previous || message.reply_to_message_id.is_some(),
                         |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()
-                                    .border_l_2()
-                                    .text_ui_xs()
-                                    .border_color(cx.theme().colors().border)
-                                    .px_1()
-                                    .py_0p5()
-                                    .child(body_text),
+                                h_flex()
+                                    .text_ui_sm()
+                                    .child(
+                                        div().absolute().child(
+                                            Avatar::new(message.sender.avatar_uri.clone())
+                                                .size(rems(1.)),
+                                        ),
+                                    )
+                                    .child(
+                                        div()
+                                            .pl(cx.rem_size() + px(6.0))
+                                            .pr(px(8.0))
+                                            .font_weight(FontWeight::BOLD)
+                                            .child(
+                                                Label::new(message.sender.github_login.clone())
+                                                    .size(LabelSize::Small),
+                                            ),
+                                    )
+                                    .child(
+                                        Label::new(time_format::format_localized_timestamp(
+                                            message.timestamp,
+                                            OffsetDateTime::now_utc(),
+                                            self.local_timezone,
+                                            time_format::TimestampFormat::EnhancedAbsolute,
+                                        ))
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                    ),
                             )
                         },
                     )
-                    .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(|| {
@@ -622,13 +600,19 @@ impl ChatPanel {
                             div()
                                 .id("reply")
                                 .child(
-                                    IconButton::new(("reply", message_id), IconName::ReplyArrow)
-                                        .on_click(cx.listener(move |this, _, cx| {
+                                    IconButton::new(
+                                        ("reply", message_id),
+                                        IconName::ReplyArrowLeft,
+                                    )
+                                    .on_click(cx.listener(
+                                        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);
                                             })
-                                        })),
+                                        },
+                                    )),
                                 )
                                 .tooltip(|cx| Tooltip::text("Reply", cx)),
                         )
@@ -689,6 +673,8 @@ impl ChatPanel {
                     "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);
@@ -743,6 +729,8 @@ impl ChatPanel {
     }
 
     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
@@ -838,6 +826,7 @@ impl ChatPanel {
     }
 
     fn close_reply_preview(&mut self, _: &CloseReplyPreview, cx: &mut ViewContext<Self>) {
+        self.selected_message_to_reply_id = None;
         self.message_editor
             .update(cx, |editor, _| editor.clear_reply_to_message_id());
     }
@@ -912,6 +901,8 @@ impl Render for ChatPanel {
                     .cloned();
 
                 el.when_some(reply_message, |el, reply_message| {
+                    let user_being_replied_to = reply_message.sender.clone();
+
                     el.child(
                         h_flex()
                             .when(!self.is_scrolled_to_bottom, |el| {
@@ -925,20 +916,28 @@ impl Render for ChatPanel {
                             .bg(cx.theme().colors().background)
                             .child(
                                 div().flex_shrink().overflow_hidden().child(
-                                    self.render_replied_to_message(None, &reply_message, cx),
+                                    h_flex()
+                                        .child(Label::new("Replying to ").size(LabelSize::Small))
+                                        .child(
+                                            div().font_weight(FontWeight::BOLD).child(
+                                                Label::new(format!(
+                                                    "@{}",
+                                                    user_being_replied_to.github_login.clone()
+                                                ))
+                                                .size(LabelSize::Small),
+                                            ),
+                                        ),
                                 ),
                             )
                             .child(
                                 IconButton::new("close-reply-preview", IconName::Close)
                                     .shape(ui::IconButtonShape::Square)
                                     .tooltip(|cx| {
-                                        Tooltip::for_action(
-                                            "Close reply preview",
-                                            &CloseReplyPreview,
-                                            cx,
-                                        )
+                                        Tooltip::for_action("Close reply", &CloseReplyPreview, cx)
                                     })
-                                    .on_click(cx.listener(move |_, _, cx| {
+                                    .on_click(cx.listener(move |this, _, cx| {
+                                        this.selected_message_to_reply_id = None;
+
                                         cx.dispatch_action(CloseReplyPreview.boxed_clone())
                                     })),
                             ),

crates/ui/src/components/icon.rs 🔗

@@ -101,7 +101,8 @@ pub enum IconName {
     ReplaceAll,
     ReplaceNext,
     Return,
-    ReplyArrow,
+    ReplyArrowRight,
+    ReplyArrowLeft,
     Screen,
     SelectAll,
     Shift,
@@ -195,7 +196,8 @@ impl IconName {
             IconName::ReplaceAll => "icons/replace_all.svg",
             IconName::ReplaceNext => "icons/replace_next.svg",
             IconName::Return => "icons/return.svg",
-            IconName::ReplyArrow => "icons/reply_arrow.svg",
+            IconName::ReplyArrowRight => "icons/reply_arrow_right.svg",
+            IconName::ReplyArrowLeft => "icons/reply_arrow_left.svg",
             IconName::Screen => "icons/desktop.svg",
             IconName::SelectAll => "icons/select_all.svg",
             IconName::Shift => "icons/shift.svg",