use std::collections::BTreeMap; use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px, }; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::{ ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex, }; use smallvec::SmallVec; type MouseDownListener = Box; type ClickListener = Box; type SharedClickListener = Rc; type DropdownBuilder = Rc) -> PopupMenu>; /// Messages from the same author within this window are grouped into one run. pub const RUN_WINDOW_SECS: u64 = 300; /// Whether a message starts a new run, given the previous message's author and unix time. pub fn opens_run(previous: Option<(T, u64)>, current: (T, u64)) -> bool { match previous { None => true, Some((previous_author, previous_at)) => { previous_author != current.0 || current.1.saturating_sub(previous_at) > RUN_WINDOW_SECS } } } /// A single message row in a chat-like list. #[derive(IntoElement)] pub struct MessageRow { id: ElementId, style: StyleRefinement, show_author: bool, hide_avatar: bool, avatar: Option, author: Option, timestamp: Option, header_extras: SmallVec<[AnyElement; 1]>, body: SmallVec<[AnyElement; 4]>, overlays: SmallVec<[AnyElement; 2]>, on_mouse_down: Option<(MouseButton, MouseDownListener)>, on_double_click: Option, } impl MessageRow { /// Create a message row with the given element id. pub fn new(id: impl Into) -> Self { Self { id: id.into(), style: StyleRefinement::default(), show_author: false, hide_avatar: false, avatar: None, author: None, timestamp: None, header_extras: SmallVec::new(), body: SmallVec::new(), overlays: SmallVec::new(), on_mouse_down: None, on_double_click: None, } } /// Whether this row opens a run of messages from one author. #[must_use] pub fn show_author(mut self, show_author: bool) -> Self { self.show_author = show_author; self } /// Hide the avatar column entirely. #[must_use] pub fn hide_avatar(mut self, hide_avatar: bool) -> Self { self.hide_avatar = hide_avatar; self } /// The avatar shown when this row opens a run of messages. #[must_use] pub fn avatar(mut self, avatar: impl IntoElement) -> Self { self.avatar = Some(avatar.into_any_element()); self } /// The author's display name, shown next to the timestamp. #[must_use] pub fn author(mut self, author: impl Into) -> Self { self.author = Some(author.into()); self } /// The time the message was sent. #[must_use] pub fn timestamp(mut self, timestamp: impl Into) -> Self { self.timestamp = Some(timestamp.into()); self } /// Append an element to the header row. #[must_use] pub fn header_extra(mut self, extra: impl IntoElement) -> Self { self.header_extras.push(extra.into_any_element()); self } /// Append an element to the message body, in order. #[must_use] pub fn child(mut self, child: impl IntoElement) -> Self { self.body.push(child.into_any_element()); self } /// Append several elements to the message body, in order. #[must_use] pub fn children(mut self, children: impl IntoIterator) -> Self { self.body .extend(children.into_iter().map(|child| child.into_any_element())); self } /// Append an element positioned over the row. #[must_use] pub fn overlay(mut self, overlay: impl IntoElement) -> Self { self.overlays.push(overlay.into_any_element()); self } /// Handle a mouse button press anywhere on the row. #[must_use] pub fn on_mouse_down( mut self, button: MouseButton, listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_mouse_down = Some((button, Box::new(listener))); self } /// Handle a double click anywhere on the row. #[must_use] pub fn on_double_click( mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_double_click = Some(Box::new(listener)); self } } impl Styled for MessageRow { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for MessageRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { id, style, show_author, hide_avatar, avatar, author, timestamp, header_extras, body, overlays, on_mouse_down, on_double_click, } = self; div() .id(id) .group("") .relative() .w_full() .py_0p5() .when(show_author, |this| this.pt_2()) .px_3() .refine_style(&style) .child( h_flex() .items_start() .gap_3() .when(!hide_avatar, |this| { if show_author { match avatar { Some(avatar) => this.child(avatar), None => this.child(div().flex_shrink_0().w(px(24.))), } } else { this.child(div().flex_shrink_0().w(px(24.))) } }) .child( v_flex() .flex_1() .w_full() .min_w_0() .flex_initial() .overflow_hidden() .text_size(px(15.)) .when(show_author, |this| { this.child( h_flex() .gap_2() .text_xs() .text_color(cx.theme().muted_foreground) .when_some(author, |this, author| { this.child(div().font_semibold().child(author)) }) .when_some(timestamp, |this, timestamp| { this.child(timestamp) }) .children(header_extras), ) }) .children(body), ), ) .children(overlays) .when_some(on_mouse_down, |this, (button, listener)| { this.on_mouse_down(button, listener) }) .when_some(on_double_click, |this, listener| { this.on_double_click(listener) }) .hover(|this| this.bg(cx.theme().list_hover)) .into_any_element() } } /// A quoted preview of a message this message replies to. #[derive(IntoElement)] pub struct ReplyPreview { id: ElementId, style: StyleRefinement, author: SharedString, preview: SharedString, on_click: Option, } impl ReplyPreview { /// Create a reply preview with the given element id, author and snippet. pub fn new( id: impl Into, author: impl Into, preview: impl Into, ) -> Self { Self { id: id.into(), style: StyleRefinement::default(), author: author.into(), preview: preview.into(), on_click: None, } } /// Handle a click anywhere on the preview. #[must_use] pub fn on_click( mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_click = Some(Box::new(listener)); self } } impl Styled for ReplyPreview { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for ReplyPreview { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { id, style, author, preview, on_click, } = self; div() .id(id) .w_full() .px_2() .border_l_2() .border_color(cx.theme().list_active) .text_xs() .refine_style(&style) .child(div().font_semibold().child(author)) .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)) .hover(|this| this.bg(cx.theme().list_hover)) .when_some(on_click, |this, on_click| this.on_click(on_click)) .into_any_element() } } /// The image media of a message: one large image or a wrapping row of thumbnails. #[derive(IntoElement)] pub struct MessageMedia { style: StyleRefinement, media: Vec, } impl MessageMedia { /// Create media from image URLs. pub fn new(media: &[SharedUri]) -> Self { Self { style: StyleRefinement::default(), media: media.to_vec(), } } } impl Styled for MessageMedia { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for MessageMedia { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { style, media } = self; if media.is_empty() { return div().into_any_element(); } if let [only] = media.as_slice() { return div() .refine_style(&style) .child( img(only.clone()) .border_1() .border_color(cx.theme().border) .h(px(250.)) .object_fit(ObjectFit::Cover) .rounded(cx.theme().radius_lg), ) .into_any_element(); } div() .w_full() .flex_1() .flex() .flex_row() .flex_wrap() .gap_2() .refine_style(&style) .children(media.into_iter().enumerate().map(|(ix, item)| { div().id(format!("media-{ix}")).flex_shrink_0().child( img(item) .h_32() .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg), ) })) .into_any_element() } } /// Reactions of one emoji with the count of authors and whether the current user reacted. pub struct ReactionGroup { pub emoji: SharedString, /// Image of a custom emoji (NIP-30) reaction pub image: Option, pub count: usize, pub mine: bool, } /// Group (emoji, image, author) triples by emoji, sorted by emoji. pub fn group_reactions( reactions: impl IntoIterator, A)>, me: Option, ) -> Vec { let mut groups: BTreeMap)> = BTreeMap::new(); for (emoji, image, author) in reactions { let group = groups.entry(emoji).or_default(); group.0 += 1; group.1 |= me.as_ref() == Some(&author); if group.2.is_none() { group.2 = image; } } groups .into_iter() .map(|(emoji, (count, mine, image))| ReactionGroup { emoji, image, count, mine, }) .collect() } /// Reaction chips shown under a message. #[derive(IntoElement)] pub struct ReactionBar { style: StyleRefinement, groups: SmallVec<[ReactionGroup; 4]>, } impl ReactionBar { /// Create a reaction bar from grouped reactions. pub fn new(groups: impl IntoIterator) -> Self { Self { style: StyleRefinement::default(), groups: groups.into_iter().collect(), } } } impl Styled for ReactionBar { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for ReactionBar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { style, groups } = self; h_flex() .mt_2() .gap_1() .refine_style(&style) .children(groups.into_iter().map(|group| { h_flex() .gap_2() .py_0p5() .px_1() .rounded(cx.theme().radius) .text_xs() .border_1() .when(group.mine, |this| { this.text_color(cx.theme().foreground) .bg(cx.theme().secondary) .border_color(cx.theme().list_active) }) .when(!group.mine, |this| this.border_color(cx.theme().border)) .when(group.image.is_none(), |this| this.child(group.emoji)) .when_some(group.image, |this, image| { this.child(img(image).size(px(16.)).flex_shrink_0()) }) .child(SharedString::from(group.count.to_string())) })) } } /// A welcome message shown at the top of a message list. #[derive(IntoElement)] pub struct WelcomeMessage { id: ElementId, style: StyleRefinement, icon: Option, title: Option, message: Option, } impl WelcomeMessage { /// Create a welcome message with the given element id. pub fn new(id: impl Into) -> Self { Self { id: id.into(), style: StyleRefinement::default(), icon: None, title: None, message: None, } } /// The icon shown above the title. #[must_use] pub fn icon(mut self, icon: impl IntoElement) -> Self { self.icon = Some(icon.into_any_element()); self } /// The welcome title. #[must_use] pub fn title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self } /// The welcome body text. #[must_use] pub fn message(mut self, message: impl Into) -> Self { self.message = Some(message.into()); self } } impl Styled for WelcomeMessage { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for WelcomeMessage { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { id, style, icon, title, message, } = self; v_flex() .id(id) .w_full() .gap_2() .p_3() .items_center() .justify_center() .text_center() .refine_style(&style) .when_some(icon, |this, icon| this.child(icon)) .child( v_flex() .items_center() .justify_center() .text_center() .when_some(title, |this, title| { this.child( div() .text_sm() .font_semibold() .text_color(cx.theme().foreground) .child(title), ) }) .when_some(message, |this, message| { this.child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(message), ) }), ) } } /// Quick reaction emojis offered in message menus. pub const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; /// Quick reaction emojis shown on a hovered message's action bar. pub const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"]; /// Fill a popup menu with reactions to a message, offering `emojis` then the custom ones. /// /// `react` receives the reaction content: an emoji, or a `:shortcode:` for a custom one. pub fn react_menu( menu: PopupMenu, emojis: &[&'static str], custom: &[String], react: impl Fn(&str, &mut Window, &mut App) + Clone + 'static, ) -> PopupMenu { let menu = emojis.iter().fold(menu, |menu, emoji| { let emoji = *emoji; let react = react.clone(); menu.item(PopupMenuItem::new(emoji).on_click(move |_, window, cx| react(emoji, window, cx))) }); if custom.is_empty() { return menu; } custom.iter().fold(menu.separator(), |menu, emoji| { let emoji = emoji.clone(); let react = react.clone(); menu.item( PopupMenuItem::new(emoji.clone()) .on_click(move |_, window, cx| react(&emoji, window, cx)), ) }) } /// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu. #[derive(IntoElement)] pub struct MessageActions { style: StyleRefinement, reactions: SmallVec<[(&'static str, SharedClickListener); 4]>, reply: Option, copy: Option<(ElementId, SharedString)>, speech: Option<(bool, SharedClickListener)>, dropdown: Option, } impl MessageActions { /// Create an empty action bar. pub fn new() -> Self { Self { style: StyleRefinement::default(), reactions: SmallVec::new(), reply: None, copy: None, speech: None, dropdown: None, } } /// Add a quick reaction button. pub fn reaction( mut self, emoji: &'static str, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.reactions.push((emoji, Rc::new(handler))); self } /// Add the reply button. pub fn reply(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { self.reply = Some(Rc::new(handler)); self } /// Add the copy button for the given value. /// The id must be unique per bar, the clipboard component keys its copied state on it. pub fn copy(mut self, id: impl Into, value: impl Into) -> Self { self.copy = Some((id.into(), value.into())); self } /// Add the read-aloud button, disabled while a message is already being spoken. pub fn speech( mut self, disabled: bool, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.speech = Some((disabled, Rc::new(handler))); self } /// Fill the bar's overflow menu. pub fn dropdown( mut self, dropdown: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> Self { self.dropdown = Some(Rc::new(dropdown)); self } } impl Default for MessageActions { fn default() -> Self { Self::new() } } impl Styled for MessageActions { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for MessageActions { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { style, reactions, reply, copy, speech, dropdown, } = self; // The leading separator only separates reactions from the center buttons let has_center = !reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some()); h_flex() .p_0p5() .gap_1() .invisible() .absolute() .right_4() .top_neg_2() .when(cx.theme().shadow, |this| this.shadow_sm()) .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) .bg(cx.theme().background) .children(reactions.into_iter().map(|(emoji, handler)| { Button::new(emoji) .label(emoji) .tooltip(emoji) .small() .ghost() .on_click(move |event, window, cx| handler(event, window, cx)) })) .when(has_center, |this| { this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) }) .when_some(reply, |this, reply| { this.child( Button::new("reply") .icon(IconName::Reply) .tooltip("Reply") .small() .ghost() .on_click(move |event, window, cx| reply(event, window, cx)), ) }) .when_some(copy, |this, (id, value)| { this.child(Clipboard::new(id).value(value).tooltip("Copy").small()) }) .when_some(speech, |this, (disabled, speech)| { this.child( Button::new("speech") .icon(crate::IconName::Speech) .tooltip("Read aloud") .small() .ghost() .disabled(disabled) .on_click(move |event, window, cx| speech(event, window, cx)), ) }) .when_some(dropdown, |this, dropdown| { this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child( Button::new("more") .icon(IconName::Ellipsis) .small() .ghost() .dropdown_menu(move |menu, window, cx| dropdown(menu, window, cx)), ) }) .group_hover("", |this| this.visible()) .refine_style(&style) } } /// A message's text revealed word by word, paced by the estimated speech duration. #[derive(IntoElement)] pub struct RevealText { text: SharedString, duration: Duration, } impl RevealText { pub fn new(text: impl Into, duration: Duration) -> Self { Self { text: text.into(), duration, } } } impl RenderOnce for RevealText { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let words: Vec = self .text .split_whitespace() .map(SharedString::from) .collect(); let total = words.len().max(1) as f32; div().flex().flex_wrap().gap_x_1().with_animation( "speech-reveal", Animation::new(self.duration).with_max_fps(30.), move |this, progress| { let visible = ((progress * total).ceil() as usize).min(words.len()); this.children(words.iter().enumerate().map(|(ix, word)| { div() .when(ix >= visible, |this| this.invisible()) .child(word.clone()) })) }, ) } }