From 9ec8b128db9d4693b40ce6ec3283d2de52989b17 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 11 Nov 2026 10:40:34 +0700 Subject: [PATCH] chore: use same composer and message actions for nip29 --- crates/chat_ui/src/lib.rs | 34 ++++++------ crates/nip29_ui/src/lib.rs | 99 ++++++++++++++++++++++++++-------- crates/nip29_ui/src/message.rs | 56 +++++++++++++++---- 3 files changed, 139 insertions(+), 50 deletions(-) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 2750355c..57b4e07a 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -2047,18 +2047,6 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) - .when(cfg!(not(target_arch = "wasm32")), |this| { - this.child( - Button::new("nutzap") - .icon(IconName::Wallet) - .tooltip("Send eCash") - .disabled(dictating) - .ghost() - .on_click(cx.listener(move |this, _ev, window, cx| { - this.open_nutzap(window, cx); - })), - ) - }) .when(dictating, |this| { this.child( h_flex() @@ -2082,17 +2070,25 @@ impl Render for ChatPanel { .flex_1(), ) }) - .child( - h_flex() - .items_end() - .gap_1() - .pl_1() - .child(SpeechButton::new(&self.speech)), - ) .child( h_flex() .pl_1() .gap_1() + .child(SpeechButton::new(&self.speech)) + .when(cfg!(not(target_arch = "wasm32")), |this| { + this.child( + Button::new("nutzap") + .icon(IconName::Wallet) + .tooltip("Send eCash") + .disabled(dictating) + .ghost() + .on_click(cx.listener( + move |this, _ev, window, cx| { + this.open_nutzap(window, cx); + }, + )), + ) + }) .child(self.render_emoji_menu(dictating, window, cx)) .child(self.render_config_menu(dictating, window, cx)) .child( diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 715afbb8..6c0a9d44 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -2,13 +2,13 @@ use std::collections::BTreeMap; use std::time::Duration; use anyhow::Result; -use chat_ui::ToggleDictation; +use chat_ui::{Command, ToggleDictation}; use gpui::prelude::FluentBuilder; use gpui::{ - Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, - ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, - px, + Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, + FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment, + ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, + WeakEntity, Window, div, list, px, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -16,6 +16,7 @@ use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{InputEvent, Textarea, TextareaState}; +use gpui_kit::component::menu::DropdownMenu; use gpui_kit::component::notification::Notification; use gpui_kit::component::scroll::Scrollbar; use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform}; @@ -28,7 +29,7 @@ use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use speech::speaker::Speaker; use state::{NostrRegistry, pick_file, upload_file}; -use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text}; +use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text}; use util::{attachment_name, display_name, opens_run, pin_label, report}; mod details; @@ -1104,7 +1105,23 @@ impl GroupPanel { }); } - fn render_composer(&self, cx: &mut Context) -> AnyElement { + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { + match command { + Command::Insert(content) => { + self.input.update(cx, |input, cx| { + let new_value = format!("{} {}", input.value(), content); + input.set_value(new_value, window, cx); + }); + } + Command::Copy(public_key) => match public_key.to_bech32() { + Ok(content) => cx.write_to_clipboard(ClipboardItem::new_string(content)), + Err(error) => log::warn!("nip29: encoding the author key failed: {error}"), + }, + _ => {} + } + } + + fn render_composer(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let notice = self.group.upgrade().and_then(|group| { let group = group.read(cx); @@ -1135,14 +1152,13 @@ impl GroupPanel { .flex_shrink_0() .w_full() .p_2() - .gap_1() + .gap_1p5() .when_some(self.reply_to, |this, target| { this.child(self.render_reply_target(target, cx)) }) .children(self.render_attachments(cx)) .child( h_flex() - .gap_1() .items_end() .child( Button::new("upload") @@ -1161,6 +1177,7 @@ impl GroupPanel { .h_8() .flex_1() .gap_2() + .pl_1() .child(SpeechWaveform::new(&self.speech).small().flex_1()) .when_some(self.speech_started, |this, started| { this.child(speech::elapsed(started, cx)) @@ -1171,28 +1188,67 @@ impl GroupPanel { this.child( Textarea::new(&self.input) .appearance(false) - .bg(cx.theme().input) .small() .min_h_8() .py_1() .flex_1(), ) }) - .child(SpeechButton::new(&self.speech)) .child( - Button::new("send") - .icon(ui::IconName::PaperPlaneFill) - .tooltip("Send") - .ghost() - .disabled(dictating || self.uploading) - .on_click(cx.listener(|this, _event, window, cx| { - this.send(window, cx); - })), + h_flex() + .pl_1() + .gap_1() + .child(SpeechButton::new(&self.speech)) + .child(self.render_emoji_menu(dictating, window, cx)) + .child( + Button::new("send") + .icon(ui::IconName::PaperPlaneFill) + .ghost() + .disabled(dictating || self.uploading) + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ), ), ) .into_any_element() } + fn render_emoji_menu( + &self, + disabled: bool, + _window: &Window, + cx: &Context, + ) -> impl IntoElement { + let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); + + let emojis = current_user + .map(|current_user| { + self.directory + .read(cx) + .person(¤t_user, cx) + .emojis() + .to_vec() + }) + .unwrap_or_default(); + + Button::new("emoji") + .icon(ui::IconName::Emoji) + .ghost() + .disabled(disabled) + .dropdown_menu_with_anchor(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()))) + }); + + emojis.iter().fold(menu, |this, (shortcode, _)| { + let label = format!(":{shortcode}:"); + this.menu(label.clone(), Box::new(Command::Insert(label))) + }) + }) + } + fn render_reply_target(&self, target: EventId, cx: &mut Context) -> AnyElement { let context = self.group.upgrade().and_then(|entity| { entity.read_with(cx, |group, cx| { @@ -1375,13 +1431,14 @@ impl Focusable for GroupPanel { } impl Render for GroupPanel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let notice = self.notice(cx); let empty = self.item_count(cx) == 2; v_flex() .size_full() .min_w_0() + .on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::toggle_dictation)) .child(self.render_encryption_notice(cx)) .child(self.render_banner(cx)) @@ -1422,6 +1479,6 @@ impl Render for GroupPanel { }) .child(Scrollbar::vertical(&self.list_state)), ) - .child(self.render_composer(cx)) + .child(self.render_composer(window, cx)) } } diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 0eda88d7..4df8e660 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -1,4 +1,5 @@ use chat::Message; +use chat_ui::Command; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ @@ -17,8 +18,8 @@ use settings::AppSettings; use speech::speaker::END_GRACE; use state::NostrRegistry; use ui::{ - Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, - group_reactions, react_menu, + Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, + ReactionBar, ReplyPreview, group_reactions, react_menu, }; use crate::GroupPanel; @@ -91,15 +92,45 @@ pub(crate) fn render( let admin = actions.admin; let pinned = actions.pinned; let group = actions.group; - let speech_panel = actions.panel; - let panel = speech_panel.clone(); + let panel = actions.panel; + let speech_panel = panel.clone(); let speech_text = message.preview(); let speech_id = message.id; let speaking = actions.speaking; + let author = message.author; - let row = row.overlay( - MessageActions::new().speech(speaking, move |_event, _window, cx| { - _ = speech_panel.update_in(cx, |panel, window, cx| { + let mut bar = MessageActions::new(); + + for emoji in COMPACT_REACTION_EMOJIS { + let emoji = *emoji; + let group = group.clone(); + let panel = panel.clone(); + + bar = bar.reaction(emoji, 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 reply_panel = panel.clone(); + let bar = bar.reply(move |_event, _window, cx| { + if let Err(error) = reply_panel.update_in(cx, |panel, window, cx| { + panel.start_reply(id, window, cx); + }) { + log::warn!("nip29: starting a reply failed: {error}"); + } + }); + + let bar = bar + .copy( + SharedString::from(format!("message-copy-{id}")), + message.preview(), + ) + .speech(speaking, move |_event, _window, cx| { + if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| { match panel.speaker.speak(&speech_text) { Ok(duration) => { panel.speech_reveal = Some((speech_id, duration)); @@ -125,9 +156,14 @@ pub(crate) fn render( window.push_notification(Notification::error(error.to_string()), cx) } } - }); - }), - ); + }) { + log::warn!("nip29: reading the message aloud failed: {error}"); + } + }); + + let row = row.overlay(bar.dropdown(move |menu, _window, _cx| { + menu.menu("Copy author", Box::new(Command::Copy(author))) + })); let custom = nostr .read(cx)