diff --git a/assets/icons/speech.svg b/assets/icons/speech.svg new file mode 100644 index 00000000..6b057003 --- /dev/null +++ b/assets/icons/speech.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index 5b312ae9..2ef1f3f0 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -14,8 +14,6 @@ pub enum Command { Relays(PublicKey), Njump(PublicKey), Trace(EventId), - Speak(EventId), - StopSpeaking, } // Start or stop dictation in the focused composer diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a674d126..aaddc47c 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -937,20 +937,6 @@ impl ChatPanel { Command::Trace(id) => { self.open_trace(id, window, cx); } - Command::Speak(id) => { - let text = self.message(id).map(|message| message.preview()); - - if let Some(text) = text - && let Err(e) = self.speaker.speak(&text) - { - window.push_notification(Notification::error(e.to_string()), cx); - } - } - Command::StopSpeaking => { - if let Err(error) = self.speaker.stop() { - window.push_notification(Notification::error(error.to_string()), cx); - } - } } } @@ -1445,6 +1431,7 @@ impl ChatPanel { let reply_id = *id; let dropdown_id = *id; + let speech_id = *id; let public_key = *public_key; let actions = actions.reply(cx.listener(move |this, _event, _window, cx| { @@ -1460,20 +1447,19 @@ impl ChatPanel { actions }; - let speaking = self.speaker.speaking(); + let actions = actions.speech(cx.listener(move |this, _event, window, cx| { + let text = this.message(&speech_id).map(|message| message.preview()); + + if let Some(text) = text + && let Err(error) = this.speaker.speak(&text) + { + window.push_notification(Notification::error(error.to_string()), cx); + } + })); actions.dropdown(move |menu, _window, _cx| { - let menu = menu - .menu("Copy author", Box::new(Command::Copy(public_key))) + menu.menu("Copy author", Box::new(Command::Copy(public_key))) .menu("Seen on", Box::new(Command::Trace(dropdown_id))) - .separator() - .menu("Read aloud", Box::new(Command::Speak(dropdown_id))); - - if speaking { - menu.menu("Stop speaking", Box::new(Command::StopSpeaking)) - } else { - menu - } }) } diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index b1aaf717..093dc9b9 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -445,8 +445,7 @@ impl GroupPanel { } } - /// Track the keys rendered by this panel: members, message authors, and - /// activity subjects + /// Track the keys rendered by this panel. fn track_participants(&mut self, cx: &mut Context) { let Some(group) = self.group.upgrade() else { return; @@ -1060,7 +1059,6 @@ impl GroupPanel { panel: cx.weak_entity(), admin, pinned, - speaking: self.speaker.speaking(), persons: self.directory.clone(), }, cx, diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 552383af..8b356f0a 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -16,8 +16,8 @@ use person::Directory; use settings::AppSettings; use state::NostrRegistry; use ui::{ - Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions, - react_menu, + Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, + group_reactions, react_menu, }; use crate::GroupPanel; @@ -27,8 +27,6 @@ pub(crate) struct Actions { pub panel: WeakEntity, pub admin: bool, pub pinned: bool, - /// Whether the panel's speaker is currently saying something - pub speaking: bool, pub persons: Entity, } @@ -90,9 +88,17 @@ pub(crate) fn render( let admin = actions.admin; let pinned = actions.pinned; let group = actions.group; - let panel = actions.panel; - let speaking = actions.speaking; - let text = message.preview(); + let speech_panel = actions.panel; + let panel = speech_panel.clone(); + let speech_text = message.preview(); + + let row = row.overlay(MessageActions::new().speech(move |_event, _window, cx| { + _ = speech_panel.update_in(cx, |panel, window, cx| { + if let Err(error) = panel.speaker.speak(&speech_text) { + window.push_notification(Notification::error(error.to_string()), cx); + } + }); + })); let custom = nostr .read(cx) @@ -142,40 +148,6 @@ pub(crate) fn render( } }); - let menu = menu.separator().item({ - let panel = panel.clone(); - let text = text.clone(); - - PopupMenuItem::new("Read aloud").on_click(move |_event, _window, cx| { - if let Err(error) = panel.update_in(cx, |panel, window, cx| { - if let Err(error) = panel.speaker.speak(&text) { - window.push_notification(Notification::error(error.to_string()), cx); - } - }) { - log::warn!("nip29: reading a message aloud failed: {error}"); - } - }) - }); - - let menu = if speaking { - menu.item({ - let panel = panel.clone(); - - PopupMenuItem::new("Stop speaking").on_click(move |_event, _window, cx| { - if let Err(error) = panel.update_in(cx, |panel, window, cx| { - if let Err(error) = panel.speaker.stop() { - window - .push_notification(Notification::error(error.to_string()), cx); - } - }) { - log::warn!("nip29: stopping the speaker failed: {error}"); - } - }) - }) - } else { - menu - }; - if !admin { return menu; } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 2d723053..473bb275 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -26,6 +26,7 @@ pub enum IconName { PlusChat, Profile, Relay, + Speech, Search, Settings, Settings2, @@ -58,6 +59,7 @@ impl IconNamed for IconName { Self::PlusChat => "icons/plus-chat.svg", Self::Profile => "icons/profile.svg", Self::Relay => "icons/relay.svg", + Self::Speech => "icons/speech.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", Self::Settings2 => "icons/settings2.svg", diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index 745f5108..b50305cf 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -597,13 +597,14 @@ pub fn react_menu( }) } -/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu. +/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu. #[derive(IntoElement)] pub struct MessageActions { style: StyleRefinement, reactions: SmallVec<[(&'static str, SharedClickListener); 4]>, reply: Option, copy: Option<(ElementId, SharedString)>, + speech: Option, dropdown: Option, } @@ -615,6 +616,7 @@ impl MessageActions { reactions: SmallVec::new(), reply: None, copy: None, + speech: None, dropdown: None, } } @@ -642,6 +644,15 @@ impl MessageActions { self } + /// Add the read-aloud button. + pub fn speech( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.speech = Some(Rc::new(handler)); + self + } + /// Fill the bar's overflow menu. pub fn dropdown( mut self, @@ -671,10 +682,13 @@ impl RenderOnce for MessageActions { reactions, reply, copy, + speech, dropdown, } = self; - let has_center = reply.is_some() || copy.is_some(); + // The leading separator only separates reactions from the center buttons + let has_center = + !reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some()); h_flex() .p_0p5() @@ -712,6 +726,16 @@ impl RenderOnce for MessageActions { .when_some(copy, |this, (id, value)| { this.child(Clipboard::new(id).value(value).tooltip("Copy").small()) }) + .when_some(speech, |this, speech| { + this.child( + Button::new("speech") + .icon(crate::IconName::Speech) + .tooltip("Read aloud") + .small() + .ghost() + .on_click(move |event, window, cx| speech(event, window, cx)), + ) + }) .when_some(dropdown, |this, dropdown| { this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child(