diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 5f1c9b91..5d985f57 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -31,13 +31,11 @@ use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::menu::DropdownMenu; +use ui::message::{MessageRow, WelcomeMessage}; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::tooltip::Tooltip; -use ui::{ - Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, - h_flex, v_flex, -}; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; use crate::file::*; use crate::text::RenderedText; @@ -45,6 +43,10 @@ use crate::text::RenderedText; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"]; +/// Notice shown when a conversation has no messages, and above the message list. +const PRIVATE_NOTICE: &str = + "This conversation is private. Only members can see each other's messages."; + /// Regex matching strings that consist entirely of emoji characters, /// zero-width joiners, variation selectors, and keycap combiners. static EMOJI_RE: LazyLock = @@ -126,9 +128,8 @@ impl ChatPanel { let replies_to = cx.new(|_| HashSet::new()); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); - // Define list of messages let messages = Vec::new(); - let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); + let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.)); // Get room id and name let (id, name) = room @@ -577,7 +578,6 @@ impl ChatPanel { where E: Into, { - let old_len = self.messages.len(); let msg: Message = m.into(); if let Err(pos) = self.messages.binary_search(&msg) { @@ -586,7 +586,8 @@ impl ChatPanel { for (i, message) in self.messages.iter().enumerate().skip(pos) { self.message_index.insert(message.id, i); } - self.list_state.splice(old_len..old_len, 1); + let len = self.list_state.item_count(); + self.list_state.splice(len..len, 1); if scroll { self.list_state.scroll_to(ListOffset { @@ -657,7 +658,8 @@ impl ChatPanel { /// Scroll to a message by its ID fn scroll_to(&self, id: &EventId) { if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) { - self.list_state.scroll_to_reveal_item(ix); + // The welcome message occupies the first row, so message rows sit one ahead. + self.list_state.scroll_to_reveal_item(ix + 1); } } @@ -1051,10 +1053,19 @@ impl ChatPanel { cx.open_url(&content); } - fn render_announcement(&self, cx: &Context) -> AnyElement { - const MSG: &str = - "This conversation is private. Only members can see each other's messages."; + fn render_welcome(&self, cx: &Context) -> AnyElement { + WelcomeMessage::new("welcome") + .icon( + svg() + .path("brand/coop.svg") + .size_12() + .text_color(cx.theme().ghost_element_background_alt), + ) + .message(PRIVATE_NOTICE) + .into_any_element() + } + fn render_announcement(&self, cx: &Context) -> AnyElement { v_flex() .h_40() .w_full() @@ -1072,7 +1083,7 @@ impl ChatPanel { .size_12() .text_color(cx.theme().ghost_element_active), ) - .child(MSG) + .child(PRIVATE_NOTICE) .into_any_element() } @@ -1141,7 +1152,14 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) -> AnyElement { - let file = self.messages.get(ix).and_then(|message| { + if ix == 0 { + return self.render_welcome(cx); + } + + // The welcome message occupies the first row, so message rows sit one ahead. + let index = ix - 1; + + let file = self.messages.get(index).and_then(|message| { let file = message.file.clone()?; (!self.decrypted_files.contains_key(&message.id) && file.is_image()) .then_some((message.id, file)) @@ -1151,9 +1169,9 @@ impl ChatPanel { self.load_file(id, file, cx); } - if let Some(message) = self.messages.get(ix) { + if let Some(message) = self.messages.get(index) { let persons = PersonRegistry::global(cx); - let show_author = self.is_group_start(ix); + let show_author = self.is_group_start(index); let text = self .rendered_texts_by_id .entry(message.id) @@ -1188,68 +1206,38 @@ impl ChatPanel { // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); - div() - .id(ix) - .group("") - .relative() - .w_full() - .py_1() - .px_3() - .child( - div() - .flex() - .gap_3() - .when(!hide_avatar, |this| { - if show_author { - this.child( - Avatar::new(author.avatar()) - .seed(author.avatar_seed()) - .flex_shrink_0() - .relative() - .dropdown_menu(move |this, _window, _cx| { - this.menu("Public Key", Box::new(Command::Copy(pk))) - .menu("View Relays", Box::new(Command::Relays(pk))) - .separator() - .menu("View on njump.me", Box::new(Command::Njump(pk))) - }), - ) - } else { - this.child(div().flex_shrink_0().w(px(32.))) - } - }) - .child( - v_flex() - .flex_1() - .w_full() - .flex_initial() - .overflow_hidden() - .when(show_author, |this| { - this.child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child(div().font_semibold().child(author.name())) - .child(message.created_at.to_human_time()) - .when(has_reports, |this| { - this.child(self.render_sent_reports(&id, cx)) - }), - ) - }) - .when(has_replies, |this| { - this.children(self.render_message_replies(replies, cx)) - }) - .when(message.file.is_none(), |this| this.child(rendered_text)) - .child(self.render_media(&message.media, cx)) - .when_some(message.file.as_ref(), |this, file| { - this.child(self.render_message_file(&id, file, cx)) - }) - .when(has_reactions, |this| { - this.child(self.render_reactions(&id, cx)) - }), - ), + MessageRow::new(ix) + .show_author(show_author) + .hide_avatar(hide_avatar) + .avatar( + Avatar::new(author.avatar()) + .seed(author.avatar_seed()) + .flex_shrink_0() + .relative() + .dropdown_menu(move |this, _window, _cx| { + this.menu("Public Key", Box::new(Command::Copy(pk))) + .menu("View Relays", Box::new(Command::Relays(pk))) + .separator() + .menu("View on njump.me", Box::new(Command::Njump(pk))) + }), ) - .child( + .author(author.name()) + .timestamp(message.created_at.to_human_time()) + .when(has_reports, |this| { + this.header_extra(self.render_sent_reports(&id, cx)) + }) + .when(has_replies, |this| { + this.children(self.render_message_replies(replies, cx)) + }) + .when(message.file.is_none(), |this| this.child(rendered_text)) + .child(self.render_media(&message.media, cx)) + .when_some(message.file.as_ref(), |this, file| { + this.child(self.render_message_file(&id, file, cx)) + }) + .when(has_reactions, |this| { + this.child(self.render_reactions(&id, cx)) + }) + .overlay( div() .group_hover("", |this| this.bg(cx.theme().element_active)) .absolute() @@ -1259,7 +1247,7 @@ impl ChatPanel { .h_full() .bg(cx.theme().border_transparent), ) - .child(self.render_actions(&id, &pk, cx)) + .overlay(self.render_actions(&id, &pk, cx)) .on_mouse_down( MouseButton::Middle, cx.listener(move |this, _, _window, cx| { @@ -1269,7 +1257,6 @@ impl ChatPanel { .on_double_click(cx.listener(move |this, _, _window, cx| { this.reply_to(&id, cx); })) - .hover(|this| this.bg(cx.theme().surface_background)) .into_any_element() } diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index 3ae23074..848a0623 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -19,6 +19,7 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; @@ -205,15 +206,15 @@ impl CommunityPanel { self.rows.clear(); self.has_more = false; self.loading = false; - self.list_state.reset(1); + self.list_state.reset(2); } channel } - /// The list's item count: the load-older row, then every message row. + /// The list's item count: the welcome row, the load-older row, then every message row. fn item_count(&self) -> usize { - self.rows.len() + 1 + self.rows.len() + 2 } /// A timeline read, `before_ms` exclusive, or `None` for the newest rows. @@ -542,7 +543,8 @@ impl CommunityPanel { shown.insert(message.id, at); self.rows.insert(at, message); - self.list_state.splice(at + 1..at + 1, 1); + // The welcome and load-older rows sit above the messages. + self.list_state.splice(at + 2..at + 2, 1); } } @@ -624,7 +626,29 @@ impl CommunityPanel { .into_any_element() } - /// The row at index 0: the affordance that pages older history in. + /// The row at index 0: the welcome message for the channel. + fn render_welcome(&self, cx: &Context) -> AnyElement { + let (name, avatar) = self + .community + .read_with(cx, |community, _cx| { + let seed = community.id().to_hex(); + let avatar = match community.icon() { + Some(path) => Avatar::from_source(path).seed(seed).large(), + None => Avatar::new(None).seed(seed).large(), + }; + + (community.name(), avatar) + }) + .unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None))); + + WelcomeMessage::new("welcome") + .icon(avatar) + .title(format!("Welcome to {}", name)) + .message(format!("This is the start of the {} channel.", name)) + .into_any_element() + } + + /// The row at index 1: the affordance that pages older history in. fn render_older(&self, cx: &mut Context) -> AnyElement { if !self.has_more { return div().into_any_element(); @@ -670,14 +694,19 @@ impl CommunityPanel { cx: &mut Context, ) -> AnyElement { if ix == 0 { + return self.render_welcome(cx); + } + + if ix == 1 { return self.render_older(cx); } - let Some(message) = self.rows.get(ix - 1) else { + // The welcome and load-older rows sit above the messages. + let Some(message) = self.rows.get(ix - 2) else { return div().into_any_element(); }; - let show_author = self.opens_run(ix - 1); + let show_author = self.opens_run(ix - 2); message::render(ix, message, show_author, cx) } diff --git a/crates/community_ui/src/message.rs b/crates/community_ui/src/message.rs index 8fd27b27..20bfe8e3 100644 --- a/crates/community_ui/src/message.rs +++ b/crates/community_ui/src/message.rs @@ -3,68 +3,37 @@ use std::collections::BTreeMap; use common::TimestampExt; use community::ChatMessage; use gpui::prelude::FluentBuilder; -use gpui::{ - AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px, -}; +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, div}; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; -use ui::{StyledExt, h_flex, v_flex}; +use ui::h_flex; +use ui::message::MessageRow; pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement { let persons = PersonRegistry::global(cx); let author = persons.read(cx).get(&message.author, cx); let hide_avatar = AppSettings::get_hide_avatar(cx); - div() - .id(ix) - .w_full() - .py_1() - .px_3() - .hover(|this| this.bg(cx.theme().surface_background)) - .child( - h_flex() - .items_start() - .gap_3() - .when(!hide_avatar, |this| { - if show_author { - this.child( - Avatar::new(author.avatar()) - .seed(author.avatar_seed()) - .flex_shrink_0(), - ) - } else { - this.child(div().flex_shrink_0().w(px(32.))) - } - }) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .when(show_author, |this| { - this.child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child(div().font_semibold().child(author.name())) - .child( - Timestamp::from_secs(message.at_ms / 1000).to_human_time(), - ) - .when(message.edited_at.is_some(), |this| { - this.child(div().child("(edited)")) - }), - ) - }) - .child(content(message, cx)) - .when(!message.reactions.is_empty(), |this| { - this.child(reactions(message, cx)) - }), - ), + MessageRow::new(ix) + .show_author(show_author) + .hide_avatar(hide_avatar) + .avatar( + Avatar::new(author.avatar()) + .seed(author.avatar_seed()) + .flex_shrink_0(), ) + .author(author.name()) + .timestamp(Timestamp::from_secs(message.at_ms / 1000).to_human_time()) + .when(message.edited_at.is_some(), |this| { + this.header_extra(div().child("(edited)")) + }) + .child(content(message, cx)) + .when(!message.reactions.is_empty(), |this| { + this.child(reactions(message, cx)) + }) .into_any_element() } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index d4b5ab75..626216e5 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -17,6 +17,7 @@ pub mod group_box; pub mod indicator; pub mod input; pub mod menu; +pub mod message; pub mod modal; pub mod nav_item; pub mod notification; diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs new file mode 100644 index 00000000..d408d1fa --- /dev/null +++ b/crates/ui/src/message.rs @@ -0,0 +1,325 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton, + MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, px, +}; +use smallvec::SmallVec; +use theme::ActiveTheme; + +use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex}; + +type MouseDownListener = Box; +type DoubleClickListener = Box; + +/// 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_1() + .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(32.))), + } + } else { + this.child(div().flex_shrink_0().w(px(32.))) + } + }) + .child( + v_flex() + .flex_1() + .w_full() + .min_w_0() + .flex_initial() + .overflow_hidden() + .when(show_author, |this| { + this.child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().text_placeholder) + .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().surface_background)) + .into_any_element() + } +} + +/// 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, + children: SmallVec<[AnyElement; 2]>, +} + +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, + children: SmallVec::new(), + } + } + + /// 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 + } + + /// Append an element below the body text. + #[must_use] + pub fn child(mut self, child: impl IntoElement) -> Self { + self.children.push(child.into_any_element()); + 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, + children, + } = 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().text) + .child(title), + ) + }) + .when_some(message, |this, message| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(message), + ) + }), + ) + .children(children) + } +} diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index 6fb997fc..985e71c7 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -127,6 +127,7 @@ impl RenderOnce for TabBar { let selected_index = self.selected_index; let on_click = self.on_click.clone(); let segmented = self.segmented; + let has_prefix = self.prefix.is_some(); self.base .group("tab-bar") @@ -145,7 +146,10 @@ impl RenderOnce for TabBar { h_flex() .id("tabs") .flex_1() - .when(!segmented, |this| this.overflow_x_scroll()) + .when(!segmented, |this| { + this.overflow_x_scroll() + .when(!has_prefix, |this| this.pl_2()) + }) .when_some(self.scroll_handle, |this, scroll_handle| { this.track_scroll(&scroll_handle) })