From 348504c33a9994ba9849f69b9075e2a6be71251e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 30 Oct 2026 14:15:14 +0700 Subject: [PATCH] . --- crates/chat_ui/src/lib.rs | 53 ++++++++++++++++++++++++---------- crates/nip29_ui/src/lib.rs | 4 +++ crates/nip29_ui/src/message.rs | 44 +++++++++++++++++++++------- crates/speech/src/speaker.rs | 4 +++ crates/ui/src/message.rs | 13 +++++---- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 3b88734f..e9719a86 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -30,7 +30,7 @@ use person::{Directory, Person}; use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; -use speech::speaker::Speaker; +use speech::speaker::{END_GRACE, Speaker}; use state::{ FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file, }; @@ -46,8 +46,7 @@ use crate::file::*; const PRIVATE_NOTICE: &str = "This conversation is private. Only members can see each other's messages."; -/// Regex matching strings that consist entirely of emoji characters, -/// zero-width joiners, variation selectors, and keycap combiners. +/// Regex matching strings that consist entirely of emoji characters. static EMOJI_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); @@ -90,9 +89,10 @@ pub struct ChatPanel { speech_started: Option, /// Reads messages aloud speaker: Speaker, - /// The message being revealed in step with speech, with its estimated duration speech_reveal: Option<(EventId, Duration)>, + /// Fallback reveal end for backends that cannot report utterance end + reveal_timeout: Option>>, /// Subject input state subject_input: Entity, /// Subject bar visibility @@ -253,6 +253,7 @@ impl ChatPanel { speech_started: None, speaker, speech_reveal: None, + reveal_timeout: None, subject_input, subject_bar, replies_to, @@ -1473,22 +1474,42 @@ impl ChatPanel { actions }; + let speaking = self.speech_reveal.is_some(); let speech_id = *id; - 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 { - match this.speaker.speak(&text) { - Ok(duration) => { - this.speech_reveal = Some((speech_id, duration)); - cx.notify(); - } - Err(error) => { - window.push_notification(Notification::error(error.to_string()), cx) + let actions = actions.speech( + speaking, + cx.listener(move |this, _event, window, cx| { + let text = this.message(&speech_id).map(|message| message.preview()); + + if let Some(text) = text { + match this.speaker.speak(&text) { + Ok(duration) => { + this.speech_reveal = Some((speech_id, duration)); + + // Re-enable speaking when no end event ever arrives + let session = (speech_id, duration); + let timer = cx.background_executor().timer(duration + END_GRACE); + this.reveal_timeout = Some(cx.spawn(async move |this, cx| { + timer.await; + this.update(cx, |this, cx| { + if this.speech_reveal.as_ref() == Some(&session) { + this.speech_reveal = None; + cx.notify(); + } + })?; + + Ok(()) + })); + cx.notify(); + } + Err(error) => { + window.push_notification(Notification::error(error.to_string()), cx) + } } } - } - })); + }), + ); actions.dropdown(move |menu, _window, _cx| { menu.menu("Copy author", Box::new(Command::Copy(public_key))) diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 02d34bde..715afbb8 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -325,6 +325,8 @@ pub struct GroupPanel { speaker: Speaker, /// The message being revealed in step with speech, with its estimated duration speech_reveal: Option<(EventId, Duration)>, + /// Fallback reveal end for backends that cannot report utterance end + reveal_timeout: Option>>, /// Uploaded, non-encrypted attachments waiting to be sent. attachments: Vec, /// Whether an upload is in flight. @@ -455,6 +457,7 @@ impl GroupPanel { speech_started: None, speaker, speech_reveal: None, + reveal_timeout: None, attachments: Vec::new(), uploading: false, tasks: smallvec![reveal_end_task], @@ -1083,6 +1086,7 @@ impl GroupPanel { panel: cx.weak_entity(), admin, pinned, + speaking: self.speech_reveal.is_some(), persons: self.directory.clone(), }, cx, diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index ef5d89c1..0eda88d7 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -14,6 +14,7 @@ use nip29::{Activity, Group, Reaction}; use nostr_sdk::prelude::{EventId, Kind}; use person::Directory; use settings::AppSettings; +use speech::speaker::END_GRACE; use state::NostrRegistry; use ui::{ Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, @@ -27,6 +28,8 @@ pub(crate) struct Actions { pub panel: WeakEntity, pub admin: bool, pub pinned: bool, + /// Whether a message is currently being spoken, disabling the speech buttons + pub speaking: bool, pub persons: Entity, } @@ -92,18 +95,39 @@ pub(crate) fn render( let panel = speech_panel.clone(); let speech_text = message.preview(); let speech_id = message.id; + let speaking = actions.speaking; - let row = row.overlay(MessageActions::new().speech(move |_event, _window, cx| { - _ = speech_panel.update_in(cx, |panel, window, cx| { - match panel.speaker.speak(&speech_text) { - Ok(duration) => { - panel.speech_reveal = Some((speech_id, duration)); - cx.notify(); + let row = row.overlay( + MessageActions::new().speech(speaking, move |_event, _window, cx| { + _ = speech_panel.update_in(cx, |panel, window, cx| { + match panel.speaker.speak(&speech_text) { + Ok(duration) => { + panel.speech_reveal = Some((speech_id, duration)); + + let session = (speech_id, duration); + let timer = cx.background_executor().timer(duration + END_GRACE); + + panel.reveal_timeout = Some(cx.spawn(async move |this, cx| { + timer.await; + this.update(cx, |this, cx| { + if this.speech_reveal.as_ref() == Some(&session) { + this.speech_reveal = None; + cx.notify(); + } + })?; + + Ok(()) + })); + + cx.notify(); + } + Err(error) => { + window.push_notification(Notification::error(error.to_string()), cx) + } } - Err(error) => window.push_notification(Notification::error(error.to_string()), cx), - } - }); - })); + }); + }), + ); let custom = nostr .read(cx) diff --git a/crates/speech/src/speaker.rs b/crates/speech/src/speaker.rs index ed5138bc..e690fdbe 100644 --- a/crates/speech/src/speaker.rs +++ b/crates/speech/src/speaker.rs @@ -5,6 +5,10 @@ use tts::Tts; /// Words per minute of an engine at its normal rate, for estimating speech duration. const NORMAL_WPM: f32 = 160.0; +/// Extra time past the estimate before speech is considered finished, for backends that +/// cannot report utterance end. +pub const END_GRACE: Duration = Duration::from_secs(5); + /// Reads text aloud through the platform's speech backend, created on first use. pub struct Speaker { tts: Option, diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index 2f28fb3f..89b124ce 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -14,7 +14,8 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::{ - ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex, + ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, + v_flex, }; use smallvec::SmallVec; @@ -605,7 +606,7 @@ pub struct MessageActions { reactions: SmallVec<[(&'static str, SharedClickListener); 4]>, reply: Option, copy: Option<(ElementId, SharedString)>, - speech: Option, + speech: Option<(bool, SharedClickListener)>, dropdown: Option, } @@ -645,12 +646,13 @@ impl MessageActions { self } - /// Add the read-aloud button. + /// Add the read-aloud button, disabled while a message is already being spoken. pub fn speech( mut self, + disabled: bool, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.speech = Some(Rc::new(handler)); + self.speech = Some((disabled, Rc::new(handler))); self } @@ -727,13 +729,14 @@ 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| { + .when_some(speech, |this, (disabled, speech)| { this.child( Button::new("speech") .icon(crate::IconName::Speech) .tooltip("Read aloud") .small() .ghost() + .disabled(disabled) .on_click(move |event, window, cx| speech(event, window, cx)), ) })