This commit is contained in:
2026-10-22 21:07:18 +07:00
parent bd9f4e2861
commit c3c8142f06
24 changed files with 398 additions and 296 deletions
+1 -59
View File
@@ -1,16 +1,13 @@
use std::hash::Hash;
use std::ops::Range;
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls, nevent_refs};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// Rendered message.
#[derive(Debug, Clone)]
pub struct Message {
@@ -248,61 +245,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
.collect()
}
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
+42 -98
View File
@@ -15,7 +15,6 @@ use gpui::{
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
@@ -34,16 +33,13 @@ use smallvec::{SmallVec, smallvec};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
};
use crate::file::*;
use crate::message::{
Avatar, MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions,
opens_run,
};
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 =
@@ -60,8 +56,6 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
mod actions;
mod file;
pub mod message;
pub mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
@@ -1050,7 +1044,7 @@ impl ChatPanel {
.rounded_full()
.bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning)
.child(Icon::new(IconName::TriangleAlert).small()),
.child(Icon::new(IconName::Warning).small()),
)
.child(
div()
@@ -1108,9 +1102,12 @@ impl ChatPanel {
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
text::rendered_text(
rendered_text(
&message.content,
&message.mentions,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
@@ -1392,83 +1389,34 @@ impl ChatPanel {
public_key: &PublicKey,
cx: &Context<Self>,
) -> impl IntoElement {
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({
let mut items = vec![];
let mut actions = MessageActions::new();
for emoji in COMPACT_REACTION_EMOJIS {
items.push(
Button::new(*emoji)
.label(*emoji)
.tooltip(*emoji)
.small()
.ghost()
.on_click({
let emoji = *emoji;
let id = *id;
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
})
}),
);
}
for emoji in COMPACT_REACTION_EMOJIS {
let id = *id;
actions = actions.reaction(
emoji,
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
}),
);
}
items
let reply_id = *id;
let copy_id = *id;
let dropdown_id = *id;
let public_key = *public_key;
actions
.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}))
.copy(cx.listener(move |this, _event, _window, cx| {
this.copy_message(&copy_id, cx);
}))
.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("reply")
.icon(IconName::Reply)
.tooltip("Reply")
.small()
.ghost()
.on_click({
let id = id.to_owned();
cx.listener(move |this, _event, _window, cx| {
this.reply_to(&id, cx);
})
}),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
.small()
.ghost()
.on_click({
let id = id.to_owned();
cx.listener(move |this, _event, _window, cx| {
this.copy_message(&id, cx);
})
}),
)
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("advance")
.icon(IconName::Ellipsis)
.small()
.ghost()
.dropdown_menu({
let public_key = *public_key;
let id = *id;
move |this, _window, _cx| {
this.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(id)))
}
}),
)
.group_hover("", |this| this.visible())
}
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
@@ -1494,7 +1442,7 @@ impl ChatPanel {
.justify_center()
.rounded_full()
.bg(red())
.child(Icon::new(IconName::X).size_2().text_color(white())),
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click({
let url = url.clone();
@@ -1695,7 +1643,7 @@ impl ChatPanel {
)
.child(
Button::new("remove-reply")
.icon(IconName::X)
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
@@ -1746,7 +1694,6 @@ impl ChatPanel {
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.large()
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
@@ -1792,9 +1739,8 @@ impl ChatPanel {
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::Sticker)
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1840,7 +1786,7 @@ impl Panel for ChatPanel {
Some(vec![
Button::new("subject")
.icon(IconName::TextCursorInput)
.icon(IconName::Input)
.tooltip("Change subject")
.small()
.ghost()
@@ -1885,7 +1831,7 @@ impl Render for ChatPanel {
.child(Input::new(&self.subject_input).small())
.child(
Button::new("change")
.icon(IconName::CircleCheck)
.icon(IconName::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
@@ -1950,7 +1896,6 @@ impl Render for ChatPanel {
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
@@ -1964,10 +1909,9 @@ impl Render for ChatPanel {
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.icon(IconName::PaperPlaneFill)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
})),
+56
View File
@@ -4,6 +4,62 @@ use nostr::prelude::*;
const BECH32_SEPARATOR: u8 = b'1';
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Nostr parsed token with its range in the original text
#[derive(Debug, Clone, PartialEq, Eq)]
+8 -9
View File
@@ -1,8 +1,6 @@
use std::collections::BTreeMap;
use anyhow::Result;
use chat_ui::message::WelcomeMessage;
use chat_ui::text::{RenderedText, rendered_text};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
@@ -26,7 +24,7 @@ use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use ui::Avatar;
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -987,7 +985,10 @@ impl GroupPanel {
.or_insert_with(|| {
rendered_text(
&message.content,
&message.mentions,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
@@ -1065,7 +1066,6 @@ impl GroupPanel {
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
@@ -1073,10 +1073,9 @@ impl GroupPanel {
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -1141,7 +1140,7 @@ impl GroupPanel {
)
.child(
Button::new("cancel-reply")
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Cancel reply")
.ghost()
.small()
@@ -1178,7 +1177,7 @@ impl GroupPanel {
)
.child(
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Remove")
.ghost()
.xsmall()
+34 -67
View File
@@ -1,5 +1,4 @@
use chat::Message;
use chat_ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -8,19 +7,20 @@ use gpui::{
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use state::NostrRegistry;
use ui::Avatar;
use ui::{
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
};
use crate::GroupPanel;
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
pub(crate) struct Actions {
pub group: WeakEntity<Group>,
pub panel: WeakEntity<GroupPanel>,
@@ -89,6 +89,22 @@ pub(crate) fn render(
let group = actions.group;
let panel = actions.panel;
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()
.map(|current_user| {
persons
.read(cx)
.person(&current_user, cx)
.emojis()
.iter()
.map(|(shortcode, _)| format!(":{shortcode}:"))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let row = div()
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
.w_full()
@@ -107,8 +123,20 @@ pub(crate) fn render(
.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
let custom = custom.clone();
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
move |menu, _window, _cx| {
let group = group.clone();
let panel = panel.clone();
react_menu(menu, REACTION_EMOJIS, &custom, move |emoji, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
})
}
});
if !admin {
@@ -143,67 +171,6 @@ pub(crate) fn render(
row.into_any_element()
}
fn react_menu(
menu: PopupMenu,
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
id: EventId,
cx: &App,
) -> PopupMenu {
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
let emoji = *emoji;
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
});
let emojis = panel
.upgrade()
.and_then(|panel| {
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
Some(
panel
.read(cx)
.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() {
return menu;
}
let menu = menu.separator();
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
let emoji = format!(":{shortcode}:");
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.clone(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
})
}
pub(crate) fn render_activity(
ix: usize,
activity: &Activity,
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
};
match (current, previous) {
(Row::Message(current), Row::Message(previous)) => chat_ui::message::opens_run(
(Row::Message(current), Row::Message(previous)) => ui::opens_run(
Some((previous.author, previous.created_at.as_secs())),
(current.author, current.created_at.as_secs()),
),
+3
View File
@@ -6,9 +6,12 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
person = { path = "../person" }
gpui.workspace = true
gpui-base.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
web-time = "1.1"
smallvec.workspace = true
log.workspace = true
+22
View File
@@ -9,16 +9,27 @@ pub enum IconName {
Book,
CaretDown,
CaretRight,
CheckCircle,
Close,
Compass,
Door,
Emoji,
Group,
Input,
Invite,
Link,
Lock,
Message,
PaperPlaneFill,
Plus,
PlusChat,
Profile,
Relay,
Search,
Settings,
Settings2,
Upload,
Warning,
}
impl IconNamed for IconName {
@@ -29,16 +40,27 @@ impl IconNamed for IconName {
Self::Book => "icons/book.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::Compass => "icons/compass.svg",
Self::Door => "icons/door.svg",
Self::Emoji => "icons/emoji.svg",
Self::Group => "icons/group.svg",
Self::Input => "icons/input.svg",
Self::Invite => "icons/invite.svg",
Self::Link => "icons/link.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
}
.into()
}
+4
View File
@@ -1,7 +1,11 @@
pub use avatar::Avatar;
pub use icon::IconName;
pub use message::*;
pub use text::*;
pub use window_drag::title_bar_drag_handlers;
pub mod avatar;
pub mod icon;
pub mod message;
pub mod text;
pub mod window_drag;
@@ -1,19 +1,25 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
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::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, InteractiveElementExt as _, StyledExt as _, h_flex, v_flex,
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
};
use smallvec::SmallVec;
pub use ui::Avatar;
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type SharedClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
/// Messages from the same author within this window are grouped into one run.
pub const RUN_WINDOW_SECS: u64 = 300;
@@ -203,6 +209,7 @@ impl RenderOnce for MessageRow {
.min_w_0()
.flex_initial()
.overflow_hidden()
.text_sm()
.when(show_author, |this| {
this.child(
h_flex()
@@ -296,7 +303,7 @@ impl RenderOnce for ReplyPreview {
.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().secondary))
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(on_click, |this, on_click| this.on_click(on_click))
.into_any_element()
}
@@ -553,3 +560,174 @@ impl RenderOnce for WelcomeMessage {
)
}
}
/// 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, and an overflow menu.
#[derive(IntoElement)]
pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<SharedClickListener>,
dropdown: Option<DropdownBuilder>,
}
impl MessageActions {
/// Create an empty action bar.
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
reactions: SmallVec::new(),
reply: None,
copy: 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.
pub fn copy(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.copy = Some(Rc::new(handler));
self
}
/// Fill the bar's overflow menu.
pub fn dropdown(
mut self,
dropdown: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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,
dropdown,
} = self;
let has_center = reply.is_some() || copy.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, copy| {
this.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
.small()
.ghost()
.on_click(move |event, window, cx| copy(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)
}
}
@@ -1,9 +1,9 @@
use std::ops::Range;
use chat::{Mention, nevent_refs};
use common::nevent_refs;
use gpui::{AnyElement, App, ElementId, Entity, IntoElement as _, SharedString, SharedUri, Window};
use gpui_kit::component::text::TextView;
use nostr_sdk::prelude::Url;
use nostr_sdk::prelude::{PublicKey, Url};
use person::Directory;
/// A span of the source content, by byte range, that renders as `text` instead.
@@ -121,22 +121,22 @@ fn nevent_bech32(url: &str) -> Option<&str> {
bech32.starts_with("nevent1").then_some(bech32)
}
/// Composes a message's rendered text: mention names, NIP-30 emojis, and nevent links.
///
/// `mentions` are the `(byte range, mentioned public key)` pairs found in `content`.
pub fn rendered_text(
content: &str,
mentions: &[Mention],
mentions: impl IntoIterator<Item = (Range<usize>, PublicKey)>,
emojis: &[(String, Url)],
persons: &Entity<Directory>,
cx: &App,
) -> RenderedText {
let mut replacements = mentions
.iter()
.map(|mention| {
.into_iter()
.map(|(range, public_key)| {
InlineReplacement::new(
mention.range.clone(),
format!(
"@{}",
persons.read(cx).person(&mention.public_key, cx).name()
),
range,
format!("@{}", persons.read(cx).person(&public_key, cx).name()),
)
})
.collect::<Vec<_>>();
+3 -3
View File
@@ -4,7 +4,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement, SharedString, Styled, WeakEntity, div, relative, uniform_list,
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
@@ -161,13 +161,13 @@ impl Sidebar {
div()
.text_xs()
.truncate()
.line_height(relative(1.2))
.line_height(relative(1.125))
.font_semibold()
.child(name),
)
.child(
div()
.text_xs()
.text_size(px(8.0))
.font_medium()
.text_color(cx.theme().muted_foreground)
.child(total_members),