From c3c8142f06c21002d5f7e37f23efd3346ab2cc9a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 22 Oct 2026 21:07:18 +0700 Subject: [PATCH] wip --- Cargo.lock | 3 + assets/icons/check-circle.svg | 3 + assets/icons/close.svg | 4 +- assets/icons/emoji.svg | 3 + assets/icons/input.svg | 3 + assets/icons/link.svg | 15 +- assets/icons/lock.svg | 15 +- assets/icons/paper-plane-fill.svg | 3 + assets/icons/plus.svg | 2 +- assets/icons/settings2.svg | 3 + assets/icons/upload.svg | 16 +- assets/icons/warning.svg | 3 + crates/chat/src/message.rs | 60 +------ crates/chat_ui/src/lib.rs | 140 +++++---------- crates/common/src/parser.rs | 56 ++++++ crates/nip29_ui/src/lib.rs | 17 +- crates/nip29_ui/src/message.rs | 101 ++++------- crates/nip29_ui/src/util.rs | 2 +- crates/ui/Cargo.toml | 3 + crates/ui/src/icon.rs | 22 +++ crates/ui/src/lib.rs | 4 + crates/{chat_ui => ui}/src/message.rs | 190 ++++++++++++++++++++- crates/{chat_ui => ui}/src/text.rs | 20 +-- crates/workspace/src/sidebar/group_view.rs | 6 +- 24 files changed, 398 insertions(+), 296 deletions(-) create mode 100644 assets/icons/check-circle.svg create mode 100644 assets/icons/emoji.svg create mode 100644 assets/icons/input.svg create mode 100644 assets/icons/paper-plane-fill.svg create mode 100644 assets/icons/settings2.svg create mode 100644 assets/icons/warning.svg rename crates/{chat_ui => ui}/src/message.rs (72%) rename crates/{chat_ui => ui}/src/text.rs (91%) diff --git a/Cargo.lock b/Cargo.lock index 4b96668f..3d116db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8207,10 +8207,13 @@ dependencies = [ name = "ui" version = "2.0.0-alpha" dependencies = [ + "common", "gpui-base", "gpui-kit", "gpui-pre", "log", + "nostr-sdk", + "person", "smallvec", "uuid", "web-time", diff --git a/assets/icons/check-circle.svg b/assets/icons/check-circle.svg new file mode 100644 index 00000000..6b1b8d0f --- /dev/null +++ b/assets/icons/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/close.svg b/assets/icons/close.svg index 03a63bc5..484ffce2 100644 --- a/assets/icons/close.svg +++ b/assets/icons/close.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/assets/icons/emoji.svg b/assets/icons/emoji.svg new file mode 100644 index 00000000..2df5b844 --- /dev/null +++ b/assets/icons/emoji.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/input.svg b/assets/icons/input.svg new file mode 100644 index 00000000..86efa0e7 --- /dev/null +++ b/assets/icons/input.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg index 645e746a..f5217bfd 100644 --- a/assets/icons/link.svg +++ b/assets/icons/link.svg @@ -1,14 +1,3 @@ - - - + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg index b2b32d12..ea03c36d 100644 --- a/assets/icons/lock.svg +++ b/assets/icons/lock.svg @@ -1,14 +1,3 @@ - - - + + diff --git a/assets/icons/paper-plane-fill.svg b/assets/icons/paper-plane-fill.svg new file mode 100644 index 00000000..8231630f --- /dev/null +++ b/assets/icons/paper-plane-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg index c4f4bd7e..ed27febe 100644 --- a/assets/icons/plus.svg +++ b/assets/icons/plus.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/settings2.svg b/assets/icons/settings2.svg new file mode 100644 index 00000000..07260972 --- /dev/null +++ b/assets/icons/settings2.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/upload.svg b/assets/icons/upload.svg index ce355073..d92353e7 100644 --- a/assets/icons/upload.svg +++ b/assets/icons/upload.svg @@ -1,15 +1 @@ - - - - - + diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg new file mode 100644 index 00000000..07eeb040 --- /dev/null +++ b/assets/icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 8b30bcdf..ce1c1552 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -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 { .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, - /// 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 { - 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 { let mut replies_to = vec![]; diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index c2c7400e..a3c853b5 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -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 = mod actions; mod file; -pub mod message; -pub mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { 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, ) -> 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(©_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) -> 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); })), diff --git a/crates/common/src/parser.rs b/crates/common/src/parser.rs index 0886654a..8ca3f374 100644 --- a/crates/common/src/parser.rs +++ b/crates/common/src/parser.rs @@ -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, + /// 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 { + 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)] diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index c88234aa..60c580a5 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -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() diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 01942136..6c670673 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -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, pub panel: WeakEntity, @@ -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(¤t_user, cx) + .emojis() + .iter() + .map(|(shortcode, _)| format!(":{shortcode}:")) + .collect::>() + }) + .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, - panel: WeakEntity, - 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(¤t_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, diff --git a/crates/nip29_ui/src/util.rs b/crates/nip29_ui/src/util.rs index 252ca8d2..f1af90a4 100644 --- a/crates/nip29_ui/src/util.rs +++ b/crates/nip29_ui/src/util.rs @@ -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()), ), diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 7fa6f03a..45e2aeea 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -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 diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 04e58bea..20b9effb 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -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() } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cc56f299..1469086a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -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; diff --git a/crates/chat_ui/src/message.rs b/crates/ui/src/message.rs similarity index 72% rename from crates/chat_ui/src/message.rs rename to crates/ui/src/message.rs index a74f8711..f8d80a18 100644 --- a/crates/chat_ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -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; type ClickListener = Box; +type SharedClickListener = Rc; +type DropdownBuilder = Rc) -> PopupMenu>; /// Messages from the same author within this window are grouped into one run. pub const RUN_WINDOW_SECS: u64 = 300; @@ -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, + copy: Option, + dropdown: Option, +} + +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 + '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) + } +} diff --git a/crates/chat_ui/src/text.rs b/crates/ui/src/text.rs similarity index 91% rename from crates/chat_ui/src/text.rs rename to crates/ui/src/text.rs index 1da19ca0..8669b448 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -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, PublicKey)>, emojis: &[(String, Url)], persons: &Entity, 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::>(); diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs index afa949e1..954ecd5c 100644 --- a/crates/workspace/src/sidebar/group_view.rs +++ b/crates/workspace/src/sidebar/group_view.rs @@ -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),