Make chat prettier (to my eyes at least)

Conrad Irwin created

Change summary

crates/channel/src/channel_chat.rs |   4 
crates/collab_ui/src/chat_panel.rs | 106 ++++++++++++++++++++++++-------
crates/gpui/src/styled.rs          |  11 ++
crates/ui/src/components/avatar.rs |  10 ++
4 files changed, 101 insertions(+), 30 deletions(-)

Detailed changes

crates/channel/src/channel_chat.rs 🔗

@@ -144,7 +144,7 @@ impl ChannelChat {
         message: MessageParams,
         cx: &mut ModelContext<Self>,
     ) -> Result<Task<Result<u64>>> {
-        if message.text.is_empty() {
+        if message.text.trim().is_empty() {
             Err(anyhow!("message body can't be empty"))?;
         }
 
@@ -174,6 +174,8 @@ impl ChannelChat {
         let user_store = self.user_store.clone();
         let rpc = self.rpc.clone();
         let outgoing_messages_lock = self.outgoing_messages_lock.clone();
+
+        // todo - handle messages that fail to send (e.g. >1024 chars)
         Ok(cx.spawn(move |this, mut cx| async move {
             let outgoing_message_guard = outgoing_messages_lock.lock().await;
             let request = rpc.request(proto::SendChannelMessage {

crates/collab_ui/src/chat_panel.rs 🔗

@@ -8,8 +8,9 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use gpui::{
     actions, div, list, prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext,
-    ClickEvent, ElementId, EventEmitter, FocusHandle, FocusableView, ListOffset, ListScrollEvent,
-    ListState, Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView,
+    ClickEvent, DismissEvent, ElementId, EventEmitter, FocusHandle, FocusableView, FontWeight,
+    ListOffset, ListScrollEvent, ListState, Model, Render, Subscription, Task, View, ViewContext,
+    VisualContext, WeakView,
 };
 use language::LanguageRegistry;
 use menu::Confirm;
@@ -22,7 +23,8 @@ use settings::{Settings, SettingsStore};
 use std::sync::Arc;
 use time::{OffsetDateTime, UtcOffset};
 use ui::{
-    prelude::*, Avatar, Button, IconButton, IconName, Key, KeyBinding, Label, TabBar, Tooltip,
+    popover_menu, prelude::*, Avatar, Button, ContextMenu, IconButton, IconName, Key, KeyBinding,
+    Label, TabBar, Tooltip,
 };
 use util::{ResultExt, TryFutureExt};
 use workspace::{
@@ -60,6 +62,7 @@ pub struct ChatPanel {
     is_scrolled_to_bottom: bool,
     markdown_data: HashMap<ChannelMessageId, RichText>,
     focus_handle: FocusHandle,
+    open_context_menu: Option<(u64, Subscription)>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -128,6 +131,7 @@ impl ChatPanel {
                 width: None,
                 markdown_data: Default::default(),
                 focus_handle: cx.focus_handle(),
+                open_context_menu: None,
             };
 
             let mut old_dock_position = this.position(cx);
@@ -348,50 +352,100 @@ impl ChatPanel {
             ChannelMessageId::Saved(id) => ("saved-message", id).into(),
             ChannelMessageId::Pending(id) => ("pending-message", id).into(),
         };
+        let this = cx.view().clone();
 
         v_stack()
             .w_full()
-            .id(element_id)
             .relative()
             .overflow_hidden()
-            .group("")
             .when(!is_continuation_from_previous, |this| {
-                this.child(
+                this.pt_3().child(
                     h_stack()
-                        .gap_2()
-                        .child(Avatar::new(message.sender.avatar_uri.clone()))
-                        .child(Label::new(message.sender.github_login.clone()))
+                        .child(
+                            div().absolute().child(
+                                Avatar::new(message.sender.avatar_uri.clone())
+                                    .size(cx.rem_size() * 1.5),
+                            ),
+                        )
+                        .child(
+                            div()
+                                .pl(cx.rem_size() * 1.5 + px(6.0))
+                                .pr(px(8.0))
+                                .font_weight(FontWeight::BOLD)
+                                .child(Label::new(message.sender.github_login.clone())),
+                        )
                         .child(
                             Label::new(format_timestamp(
                                 message.timestamp,
                                 now,
                                 self.local_timezone,
                             ))
+                            .size(LabelSize::Small)
                             .color(Color::Muted),
                         ),
                 )
             })
-            .when(!is_continuation_to_next, |this|
-                // HACK: This should really be a margin, but margins seem to get collapsed.
-                this.pb_2())
-            .child(text.element("body".into(), cx))
+            .when(is_continuation_from_previous, |this| this.pt_1())
             .child(
-                div()
-                    .absolute()
-                    .top_1()
-                    .right_2()
-                    .w_8()
-                    .visible_on_hover("")
-                    .children(message_id_to_remove.map(|message_id| {
-                        IconButton::new(("remove", message_id), IconName::XCircle).on_click(
-                            cx.listener(move |this, _, cx| {
-                                this.remove_message(message_id, cx);
-                            }),
-                        )
-                    })),
+                v_stack()
+                    .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(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))
+                                    })
+                            })),
+                    ),
             )
     }
 
+    fn has_open_menu(&self, message_id: Option<u64>) -> bool {
+        match self.open_context_menu.as_ref() {
+            Some((id, _)) => Some(*id) == message_id,
+            None => false,
+        }
+    }
+
+    fn render_message_menu(
+        this: &View<Self>,
+        message_id: u64,
+        cx: &mut WindowContext,
+    ) -> View<ContextMenu> {
+        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))
+                })
+            })
+        };
+        this.update(cx, |this, cx| {
+            let subscription = cx.subscribe(&menu, |this: &mut Self, _, _: &DismissEvent, _| {
+                this.open_context_menu = None;
+            });
+            this.open_context_menu = Some((message_id, subscription));
+        });
+        menu
+    }
+
     fn render_markdown_with_mentions(
         language_registry: &Arc<LanguageRegistry>,
         current_user_id: u64,

crates/gpui/src/styled.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     self as gpui, hsla, point, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle,
-    DefiniteLength, Display, Fill, FlexDirection, Hsla, JustifyContent, Length, Position,
-    SharedString, StyleRefinement, Visibility, WhiteSpace,
+    DefiniteLength, Display, Fill, FlexDirection, FontWeight, Hsla, JustifyContent, Length,
+    Position, SharedString, StyleRefinement, Visibility, WhiteSpace,
 };
 use crate::{BoxShadow, TextStyleRefinement};
 use smallvec::{smallvec, SmallVec};
@@ -494,6 +494,13 @@ pub trait Styled: Sized {
         self
     }
 
+    fn font_weight(mut self, weight: FontWeight) -> Self {
+        self.text_style()
+            .get_or_insert_with(Default::default)
+            .font_weight = Some(weight);
+        self
+    }
+
     fn text_bg(mut self, bg: impl Into<Hsla>) -> Self {
         self.text_style()
             .get_or_insert_with(Default::default)

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

@@ -26,6 +26,7 @@ pub enum AvatarShape {
 #[derive(IntoElement)]
 pub struct Avatar {
     image: Img,
+    size: Option<Pixels>,
     border_color: Option<Hsla>,
     is_available: Option<bool>,
 }
@@ -36,7 +37,7 @@ impl RenderOnce for Avatar {
             self = self.shape(AvatarShape::Circle);
         }
 
-        let size = cx.rem_size();
+        let size = self.size.unwrap_or_else(|| cx.rem_size());
 
         div()
             .size(size + px(2.))
@@ -78,6 +79,7 @@ impl Avatar {
             image: img(src),
             is_available: None,
             border_color: None,
+            size: None,
         }
     }
 
@@ -124,4 +126,10 @@ impl Avatar {
         self.is_available = is_available.into();
         self
     }
+
+    /// Size overrides the avatar size. By default they are 1rem.
+    pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
+        self.size = size.into();
+        self
+    }
 }