From de1db8b6be03ca4fd4ecad029805d777017e8eb7 Mon Sep 17 00:00:00 2001 From: Evren Sen <146845123+evrsen@users.noreply.github.com> Date: Fri, 15 Mar 2024 03:45:53 +0100 Subject: [PATCH] Rework/redesign message replies (#9049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../{reply_arrow.svg => 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(-) rename assets/icons/{reply_arrow.svg => reply_arrow_left.svg} (100%) create mode 100644 assets/icons/reply_arrow_right.svg diff --git a/assets/icons/reply_arrow.svg b/assets/icons/reply_arrow_left.svg similarity index 100% rename from assets/icons/reply_arrow.svg rename to assets/icons/reply_arrow_left.svg diff --git a/assets/icons/reply_arrow_right.svg b/assets/icons/reply_arrow_right.svg new file mode 100644 index 0000000000000000000000000000000000000000..66d4a1bc0ae68fb0798843823458c480be380b15 --- /dev/null +++ b/assets/icons/reply_arrow_right.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index d2410c90e00a3bf84ec0f6531ef50cb8c85d68a5..7329f423eba279f89ad3a0d64b3b8c4a70335206 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/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, + selected_message_to_reply_id: Option, } #[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, - reply_to_message: &ChannelMessage, + reply_to_message: &Option, 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 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.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.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()) })), ), diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index d780884c0a079019eb616a95ddf13618352ca52d..bd1f795bce3c268a8dcd634faa58cab26b25b080 100644 --- a/crates/ui/src/components/icon.rs +++ b/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",