From b4ce9368e2cd6668465a654191df5e0cada3730c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 15:45:13 +0700 Subject: [PATCH] chore: extract shared message components --- crates/chat_ui/src/lib.rs | 162 ++++------------------ crates/nip29_ui/src/message.rs | 116 ++++------------ crates/nip29_ui/src/util.rs | 17 +-- crates/ui/src/message.rs | 237 ++++++++++++++++++++++++++++++++- 4 files changed, 287 insertions(+), 245 deletions(-) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7b57aaef..54d48839 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -10,10 +10,9 @@ use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, - ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, - StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, - SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, - retain_all, svg, white, + ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, + Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, + WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white, }; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -30,7 +29,9 @@ 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::message::{ + MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, opens_run, +}; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::tooltip::Tooltip; @@ -1037,29 +1038,17 @@ impl ChatPanel { } fn is_group_start(&self, ix: usize) -> bool { - // 5 minutes - const GROUP_WINDOW: u64 = 300; - if ix == 0 { return true; } - if let Some(previous) = self.messages.get(ix - 1) - && let Some(current) = self.messages.get(ix) - { - if current.author != previous.author { - return true; - } - - let gap = current - .created_at - .as_secs() - .saturating_sub(previous.created_at.as_secs()); - - return gap > GROUP_WINDOW; + match (self.messages.get(ix - 1), self.messages.get(ix)) { + (Some(previous), Some(current)) => opens_run( + Some((previous.author, previous.created_at.as_secs())), + (current.author, current.created_at.as_secs()), + ), + _ => false, } - - false } fn render_message( @@ -1119,8 +1108,13 @@ impl ChatPanel { let has_reactions = self.has_reaction(&id); let has_reports = self.has_reports(&id); - // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); + let nostr = NostrRegistry::global(cx); + + let reactions = ReactionBar::new(group_reactions( + self.reaction(&id).iter().cloned(), + nostr.read(cx).current_user(), + )); MessageRow::new(ix) .show_author(show_author) @@ -1146,13 +1140,11 @@ impl ChatPanel { 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)) + .child(MessageMedia::new(&message.media)) .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)) - }) + .when(has_reactions, |this| this.child(reactions)) .overlay( div() .group_hover("", |this| this.bg(cx.theme().element_active)) @@ -1176,55 +1168,6 @@ impl ChatPanel { .into_any_element() } - fn render_media(&self, media: &[SharedUri], cx: &Context) -> impl IntoElement { - // No media: return empty div - if media.is_empty() { - return div(); - }; - - // Single media item: render full-width image - if media.len() == 1 { - return div().child( - img(media[0].clone()) - .border_1() - .border_color(cx.theme().border_faint) - .h(px(250.)) - .object_fit(ObjectFit::Cover) - .rounded(px(theme::bubble_radius())), - ); - } - - // Multiple media items: render in a row - div() - .w_full() - .flex_1() - .flex() - .flex_row() - .flex_wrap() - .gap(px(theme::SPACE)) - .children({ - let mut items = vec![]; - - for (ix, item) in media.iter().enumerate() { - items.push( - div() - .id(format!("media-{ix}")) - .flex_grow_0() - .flex_shrink_0() - .child( - img(item.clone()) - .h_32() - .border_1() - .border_color(cx.theme().border_faint) - .rounded(px(theme::bubble_radius())), - ), - ); - } - - items - }) - } - fn render_message_replies( &self, replies: &[EventId], @@ -1239,71 +1182,18 @@ impl ChatPanel { let author = self.profile(&message.author, cx); items.push( - div() - .id(ix) - .w_full() - .px_2() - .border_l_2() - .border_color(cx.theme().element_active) - .text_style(TextStyle::Callout) - .child(div().font_semibold().child(author.name())) - .child( - div() - .w_full() - .text_ellipsis() - .line_clamp(1) - .child(message.preview()), - ) - .hover(|this| this.bg(cx.theme().surface_raised)) - .on_click({ - let id = *id; - cx.listener(move |this, _event, _window, _cx| { - this.scroll_to(&id); - }) - }), + ReplyPreview::new(ix, author.name(), message.preview()).on_click({ + let id = *id; + cx.listener(move |this, _event, _window, _cx| { + this.scroll_to(&id); + }) + }), ); } items } - fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement { - let current_user = NostrRegistry::global(cx).read(cx).current_user(); - let reactions = self.reaction(id); - - // Group reactions by emoji and collect authors for each - let mut grouped: BTreeMap> = BTreeMap::new(); - for (emoji, author) in reactions { - grouped.entry(emoji.clone()).or_default().push(*author); - } - - h_flex() - .mt_2() - .gap_1() - .children(grouped.into_iter().map(|(emoji, authors)| { - let count = authors.len(); - let has_reacted = current_user - .map(|pk| authors.contains(&pk)) - .unwrap_or(false); - - h_flex() - .gap(px(theme::SPACE)) - .py_0p5() - .px_1() - .rounded(px(theme::control_radius())) - .text_style(TextStyle::Caption) - .border_1() - .when(has_reacted, |this| { - this.text_color(cx.theme().text) - .bg(cx.theme().surface_raised) - .border_color(cx.theme().element_active) - }) - .when(!has_reacted, |this| this.border_color(cx.theme().border)) - .child(emoji) - .child(SharedString::from(count.to_string())) - })) - } - fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { let reports = self.sent_reports(id); diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 7bc74c6f..865ad122 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -1,21 +1,19 @@ -use std::collections::BTreeMap; - use chat::Message; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString, - SharedUri, StatefulInteractiveElement, Styled, StyledImage, WeakEntity, div, img, px, + AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, div, px, }; use nip29::{Activity, Group, Reaction}; -use nostr_sdk::prelude::{EventId, Kind, PublicKey}; +use nostr_sdk::prelude::{EventId, Kind}; use person::PersonRegistry; use settings::AppSettings; use state::NostrRegistry; -use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius, control_radius}; +use theme::{ActiveTheme, TextStyle, Typeset as _}; use ui::avatar::Avatar; use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem}; -use ui::message::MessageRow; +use ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; use crate::GroupPanel; @@ -38,10 +36,19 @@ pub(crate) fn render( actions: Actions, cx: &App, ) -> AnyElement { + let nostr = NostrRegistry::global(cx); let persons = PersonRegistry::global(cx); - let author = persons.read(cx).get(&message.author, cx); let hide_avatar = AppSettings::get_hide_avatar(cx); + let author = persons.read(cx).get(&message.author, cx); + + let reaction_groups = group_reactions( + reactions + .iter() + .map(|reaction| (SharedString::from(reaction.emoji.as_str()), reaction.author)), + nostr.read(cx).current_user(), + ); + let row = MessageRow::new(ix) .show_author(show_author) .hide_avatar(hide_avatar) @@ -62,10 +69,10 @@ pub(crate) fn render( }) .child(content) .when(!message.media.is_empty(), |this| { - this.child(media(&message.media, cx)) + this.child(MessageMedia::new(&message.media)) }) - .when(!reactions.is_empty(), |this| { - this.child(reaction_bar(reactions, cx)) + .when(!reaction_groups.is_empty(), |this| { + this.child(ReactionBar::new(reaction_groups)) }) .into_any_element(); @@ -226,43 +233,6 @@ fn action(activity: &Activity) -> String { } } -fn reaction_bar(reactions: &[Reaction], cx: &App) -> AnyElement { - let me = NostrRegistry::global(cx).read(cx).current_user(); - - let mut grouped: BTreeMap> = BTreeMap::new(); - - for reaction in reactions { - grouped - .entry(reaction.emoji.clone()) - .or_default() - .push(reaction.author); - } - - h_flex() - .mt_1() - .gap_1() - .children(grouped.into_iter().map(|(emoji, authors)| { - let mine = me.is_some_and(|me| authors.contains(&me)); - - h_flex() - .gap(px(theme::SPACE)) - .py_0p5() - .px_1() - .rounded(px(control_radius())) - .text_style(TextStyle::Caption) - .border_1() - .when(mine, |this| { - this.text_color(cx.theme().text) - .bg(cx.theme().surface_raised) - .border_color(cx.theme().element_active) - }) - .when(!mine, |this| this.border_color(cx.theme().border)) - .child(SharedString::from(emoji)) - .child(SharedString::from(authors.len().to_string())) - })) - .into_any_element() -} - fn replies( targets: &[EventId], group: &WeakEntity, @@ -295,20 +265,13 @@ fn replies( let panel = panel.clone(); items.push( - div() - .id(ix) - .w_full() - .px_2() - .border_l_2() - .border_color(cx.theme().element_active) - .text_style(TextStyle::Callout) - .child(div().font_semibold().child(author)) - .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)) - .hover(|this| this.bg(cx.theme().surface_raised)) + ReplyPreview::new(ix, author, preview) .on_click(move |_event, _window, cx| { - let _ = panel.update(cx, |panel, cx| { + if let Err(error) = panel.update(cx, |panel, cx| { panel.scroll_to(&id, cx); - }); + }) { + log::warn!("nip29: scrolling to message failed: {error}"); + } }) .into_any_element(), ); @@ -316,36 +279,3 @@ fn replies( items } - -fn media(media: &[SharedUri], cx: &App) -> AnyElement { - if let [only] = media { - return div() - .child( - img(only.clone()) - .border_1() - .border_color(cx.theme().border_faint) - .h(px(250.)) - .object_fit(ObjectFit::Cover) - .rounded(px(bubble_radius())), - ) - .into_any_element(); - } - - div() - .w_full() - .flex_1() - .flex() - .flex_row() - .flex_wrap() - .gap(px(theme::SPACE)) - .children(media.iter().enumerate().map(|(ix, item)| { - div().id(format!("media-{ix}")).child( - img(item.clone()) - .h_32() - .border_1() - .border_color(cx.theme().border_faint) - .rounded(px(bubble_radius())), - ) - })) - .into_any_element() -} diff --git a/crates/nip29_ui/src/util.rs b/crates/nip29_ui/src/util.rs index a15589d2..c50476a6 100644 --- a/crates/nip29_ui/src/util.rs +++ b/crates/nip29_ui/src/util.rs @@ -7,8 +7,6 @@ use ui::input::{Input, InputState}; use ui::notification::Notification; use ui::{WindowExtension, v_flex}; -const RUN_WINDOW_SECS: u64 = 300; - pub(crate) fn display_name(group: &Group) -> SharedString { group .metadata() @@ -37,16 +35,13 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool { return true; }; - if !matches!((current, previous), (Row::Message(_), Row::Message(_))) { - return true; + match (current, previous) { + (Row::Message(current), Row::Message(previous)) => ui::message::opens_run( + Some((previous.author, previous.created_at.as_secs())), + (current.author, current.created_at.as_secs()), + ), + _ => true, } - - current.author() != previous.author() - || current - .created_at() - .as_secs() - .saturating_sub(previous.created_at().as_secs()) - > RUN_WINDOW_SECS } pub(crate) fn field(label: &'static str, input: &Entity, cx: &App) -> Div { diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index d1717f79..99abb744 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -1,16 +1,31 @@ +use std::collections::BTreeMap; + 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, + MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri, + StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px, }; use smallvec::SmallVec; -use theme::{ActiveTheme, TextStyle, Typeset as _}; +use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius, control_radius}; use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex}; type MouseDownListener = Box; -type DoubleClickListener = Box; +type ClickListener = Box; + +/// 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)] @@ -26,7 +41,7 @@ pub struct MessageRow { body: SmallVec<[AnyElement; 4]>, overlays: SmallVec<[AnyElement; 2]>, on_mouse_down: Option<(MouseButton, MouseDownListener)>, - on_double_click: Option, + on_double_click: Option, } impl MessageRow { @@ -217,6 +232,218 @@ impl RenderOnce for MessageRow { } } +/// 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().element_active) + .text_style(TextStyle::Callout) + .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().surface_raised)) + .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_faint) + .h(px(250.)) + .object_fit(ObjectFit::Cover) + .rounded(px(bubble_radius())), + ) + .into_any_element(); + } + + div() + .w_full() + .flex_1() + .flex() + .flex_row() + .flex_wrap() + .gap(px(theme::SPACE)) + .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_faint) + .rounded(px(bubble_radius())), + ) + })) + .into_any_element() + } +} + +/// Reactions of one emoji with the count of authors and whether the current user reacted. +pub struct ReactionGroup { + pub emoji: SharedString, + pub count: usize, + pub mine: bool, +} + +/// Group (emoji, author) pairs by emoji, sorted by emoji, marking groups containing `me`. +pub fn group_reactions( + reactions: impl IntoIterator, + me: Option, +) -> Vec { + let mut groups: BTreeMap = BTreeMap::new(); + + for (emoji, author) in reactions { + let group = groups.entry(emoji).or_default(); + group.0 += 1; + group.1 |= me.as_ref() == Some(&author); + } + + groups + .into_iter() + .map(|(emoji, (count, mine))| ReactionGroup { emoji, 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(px(theme::SPACE)) + .py_0p5() + .px_1() + .rounded(px(control_radius())) + .text_style(TextStyle::Caption) + .border_1() + .when(group.mine, |this| { + this.text_color(cx.theme().text) + .bg(cx.theme().surface_raised) + .border_color(cx.theme().element_active) + }) + .when(!group.mine, |this| this.border_color(cx.theme().border)) + .child(group.emoji) + .child(SharedString::from(group.count.to_string())) + })) + } +} + /// A welcome message shown at the top of a message list. #[derive(IntoElement)] pub struct WelcomeMessage {