chore: extract shared message components
This commit is contained in:
+26
-136
@@ -10,10 +10,9 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white,
|
||||||
retain_all, svg, white,
|
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -30,7 +29,9 @@ use ui::button::{Button, ButtonVariants};
|
|||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use ui::menu::DropdownMenu;
|
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::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::tooltip::Tooltip;
|
use ui::tooltip::Tooltip;
|
||||||
@@ -1037,29 +1038,17 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_group_start(&self, ix: usize) -> bool {
|
fn is_group_start(&self, ix: usize) -> bool {
|
||||||
// 5 minutes
|
|
||||||
const GROUP_WINDOW: u64 = 300;
|
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(previous) = self.messages.get(ix - 1)
|
match (self.messages.get(ix - 1), self.messages.get(ix)) {
|
||||||
&& let Some(current) = self.messages.get(ix)
|
(Some(previous), Some(current)) => opens_run(
|
||||||
{
|
Some((previous.author, previous.created_at.as_secs())),
|
||||||
if current.author != previous.author {
|
(current.author, current.created_at.as_secs()),
|
||||||
return true;
|
),
|
||||||
}
|
_ => false,
|
||||||
|
|
||||||
let gap = current
|
|
||||||
.created_at
|
|
||||||
.as_secs()
|
|
||||||
.saturating_sub(previous.created_at.as_secs());
|
|
||||||
|
|
||||||
return gap > GROUP_WINDOW;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
@@ -1119,8 +1108,13 @@ impl ChatPanel {
|
|||||||
let has_reactions = self.has_reaction(&id);
|
let has_reactions = self.has_reaction(&id);
|
||||||
let has_reports = self.has_reports(&id);
|
let has_reports = self.has_reports(&id);
|
||||||
|
|
||||||
// Hide avatar setting
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
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)
|
MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
@@ -1146,13 +1140,11 @@ impl ChatPanel {
|
|||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
.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| {
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
})
|
})
|
||||||
.when(has_reactions, |this| {
|
.when(has_reactions, |this| this.child(reactions))
|
||||||
this.child(self.render_reactions(&id, cx))
|
|
||||||
})
|
|
||||||
.overlay(
|
.overlay(
|
||||||
div()
|
div()
|
||||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||||
@@ -1176,55 +1168,6 @@ impl ChatPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> 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(
|
fn render_message_replies(
|
||||||
&self,
|
&self,
|
||||||
replies: &[EventId],
|
replies: &[EventId],
|
||||||
@@ -1239,71 +1182,18 @@ impl ChatPanel {
|
|||||||
let author = self.profile(&message.author, cx);
|
let author = self.profile(&message.author, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
ReplyPreview::new(ix, author.name(), message.preview()).on_click({
|
||||||
.id(ix)
|
let id = *id;
|
||||||
.w_full()
|
cx.listener(move |this, _event, _window, _cx| {
|
||||||
.px_2()
|
this.scroll_to(&id);
|
||||||
.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);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
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<SharedString, Vec<PublicKey>> = 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 {
|
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||||
let reports = self.sent_reports(id);
|
let reports = self.sent_reports(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use chat::Message;
|
use chat::Message;
|
||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
|
||||||
SharedUri, StatefulInteractiveElement, Styled, StyledImage, WeakEntity, div, img, px,
|
WeakEntity, div, px,
|
||||||
};
|
};
|
||||||
use nip29::{Activity, Group, Reaction};
|
use nip29::{Activity, Group, Reaction};
|
||||||
use nostr_sdk::prelude::{EventId, Kind, PublicKey};
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use state::NostrRegistry;
|
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::avatar::Avatar;
|
||||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
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 ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||||
|
|
||||||
use crate::GroupPanel;
|
use crate::GroupPanel;
|
||||||
@@ -38,10 +36,19 @@ pub(crate) fn render(
|
|||||||
actions: Actions,
|
actions: Actions,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
let persons = PersonRegistry::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 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)
|
let row = MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
.hide_avatar(hide_avatar)
|
.hide_avatar(hide_avatar)
|
||||||
@@ -62,10 +69,10 @@ pub(crate) fn render(
|
|||||||
})
|
})
|
||||||
.child(content)
|
.child(content)
|
||||||
.when(!message.media.is_empty(), |this| {
|
.when(!message.media.is_empty(), |this| {
|
||||||
this.child(media(&message.media, cx))
|
this.child(MessageMedia::new(&message.media))
|
||||||
})
|
})
|
||||||
.when(!reactions.is_empty(), |this| {
|
.when(!reaction_groups.is_empty(), |this| {
|
||||||
this.child(reaction_bar(reactions, cx))
|
this.child(ReactionBar::new(reaction_groups))
|
||||||
})
|
})
|
||||||
.into_any_element();
|
.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<String, Vec<PublicKey>> = 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(
|
fn replies(
|
||||||
targets: &[EventId],
|
targets: &[EventId],
|
||||||
group: &WeakEntity<Group>,
|
group: &WeakEntity<Group>,
|
||||||
@@ -295,20 +265,13 @@ fn replies(
|
|||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
ReplyPreview::new(ix, author, preview)
|
||||||
.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))
|
|
||||||
.on_click(move |_event, _window, cx| {
|
.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);
|
panel.scroll_to(&id, cx);
|
||||||
});
|
}) {
|
||||||
|
log::warn!("nip29: scrolling to message failed: {error}");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
);
|
);
|
||||||
@@ -316,36 +279,3 @@ fn replies(
|
|||||||
|
|
||||||
items
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ use ui::input::{Input, InputState};
|
|||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
const RUN_WINDOW_SECS: u64 = 300;
|
|
||||||
|
|
||||||
pub(crate) fn display_name(group: &Group) -> SharedString {
|
pub(crate) fn display_name(group: &Group) -> SharedString {
|
||||||
group
|
group
|
||||||
.metadata()
|
.metadata()
|
||||||
@@ -37,16 +35,13 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !matches!((current, previous), (Row::Message(_), Row::Message(_))) {
|
match (current, previous) {
|
||||||
return true;
|
(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<InputState>, cx: &App) -> Div {
|
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||||
|
|||||||
+232
-5
@@ -1,16 +1,31 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
|
||||||
div, px,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
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};
|
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
||||||
|
|
||||||
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||||
type DoubleClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||||
|
|
||||||
|
/// 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<T: PartialEq>(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.
|
/// A single message row in a chat-like list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -26,7 +41,7 @@ pub struct MessageRow {
|
|||||||
body: SmallVec<[AnyElement; 4]>,
|
body: SmallVec<[AnyElement; 4]>,
|
||||||
overlays: SmallVec<[AnyElement; 2]>,
|
overlays: SmallVec<[AnyElement; 2]>,
|
||||||
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
||||||
on_double_click: Option<DoubleClickListener>,
|
on_double_click: Option<ClickListener>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageRow {
|
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<ClickListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplyPreview {
|
||||||
|
/// Create a reply preview with the given element id, author and snippet.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
author: impl Into<SharedString>,
|
||||||
|
preview: impl Into<SharedString>,
|
||||||
|
) -> 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<SharedUri>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<A: PartialEq>(
|
||||||
|
reactions: impl IntoIterator<Item = (SharedString, A)>,
|
||||||
|
me: Option<A>,
|
||||||
|
) -> Vec<ReactionGroup> {
|
||||||
|
let mut groups: BTreeMap<SharedString, (usize, bool)> = 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<Item = ReactionGroup>) -> 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.
|
/// A welcome message shown at the top of a message list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct WelcomeMessage {
|
pub struct WelcomeMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user