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(