From 733ad6e63fa6408e47d87a22cf51a784f5ce103f Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:00:12 -0400 Subject: [PATCH 1/6] feat(typing): Implement typing indicators - Send TypingMessage Started/Stopped events as the user composes a message, including a periodic refresh and an idle-stop timer so the indicator follows actual composition activity. - Display a typing indicator strip above the message input, gated on the active channel's is-typing state. - Add the show-typing-indicators and send-typing-indicators settings, exposed through a new preferences group, and honour them both for display and outbound events. - Generalise Channel-level send_message_to_group to accept any ContentBody so the new TypingMessage path can reuse it. --- CHANGELOG.md | 5 + data/de.schmidhuberj.Flare.gschema.xml | 9 + data/resources/style.css | 6 + data/resources/ui/channel_messages.blp | 33 +++ data/resources/ui/preferences_window.blp | 15 ++ src/backend/channel.rs | 59 +++++- src/backend/manager.rs | 43 +++- src/backend/manager_thread.rs | 8 +- src/backend/message/mod.rs | 12 +- src/gui/channel_messages.rs | 251 ++++++++++++++++++++++- src/gui/preferences_window.rs | 23 +++ 11 files changed, 443 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20dc578..2bde927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Send typing indicators while composing a message and display them above the message input. +- Settings to enable or disable sending and showing typing indicators. + ## [0.20.4] - 2026-04-22 ### Fixed diff --git a/data/de.schmidhuberj.Flare.gschema.xml b/data/de.schmidhuberj.Flare.gschema.xml index 8a58415..0705a73 100644 --- a/data/de.schmidhuberj.Flare.gschema.xml +++ b/data/de.schmidhuberj.Flare.gschema.xml @@ -58,6 +58,15 @@ Send a message when the Enter-key is pressed + + true + Show typing indicators of other users + + + true + Send typing indicators while composing + + "firstname" How to sort contacts, e.g with "firstname" or "surname" diff --git a/data/resources/style.css b/data/resources/style.css index dcd0569..00e4783 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -13,6 +13,12 @@ border-top: 1px solid @borders; } +.typing-indicator { + background-color: @window_bg_color; + border-top: 1px solid @borders; + min-height: 18px; +} + .message-list row { padding:0; } diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp index 53be7ab..7f438e4 100644 --- a/data/resources/ui/channel_messages.blp +++ b/data/resources/ui/channel_messages.blp @@ -102,6 +102,39 @@ template $FlChannelMessages: Box { } } + // Typing indicator + Box typing_indicator { + styles [ + "typing-indicator", + ] + + orientation: horizontal; + hexpand: true; + visible: bind template.show-typing as ; + + Adw.Clamp { + maximum-size: 800; + tightening-threshold: 600; + hexpand: true; + + Label { + styles [ + "caption", + "dim-label", + ] + + halign: start; + ellipsize: end; + xalign: 0; + margin-start: 12; + margin-end: 12; + margin-top: 2; + margin-bottom: 2; + label: bind template.active-channel as <$FlChannel>.typing-label; + } + } + } + Box { styles [ "toolbar", diff --git a/data/resources/ui/preferences_window.blp b/data/resources/ui/preferences_window.blp index dd84f74..2068cab 100644 --- a/data/resources/ui/preferences_window.blp +++ b/data/resources/ui/preferences_window.blp @@ -66,6 +66,21 @@ template $FlPreferencesWindow: Adw.PreferencesDialog { ); } } + + Adw.PreferencesGroup { + title: _("Typing Indicators"); + description: _("Inform other users when you are composing a message and show indicators when they are"); + + Adw.SwitchRow row_send_typing_indicators { + title: _("Send Typing Indicators"); + subtitle: _("Notify others while you are composing a message"); + } + + Adw.SwitchRow row_show_typing_indicators { + title: _("Show Typing Indicators"); + subtitle: _("Display when other users are composing a message"); + } + } } } diff --git a/src/backend/channel.rs b/src/backend/channel.rs index 73e82f3..4bb1d38 100644 --- a/src/backend/channel.rs +++ b/src/backend/channel.rs @@ -15,8 +15,9 @@ use glib::Bytes; use glib::{Object, prelude::Cast}; use libsignal_service::{ - proto::{DataMessage, GroupContextV2}, + proto::{DataMessage, GroupContextV2, TypingMessage, typing_message::Action as TypingAction}, protocol::ServiceId, + zkgroup::groups::{GroupMasterKey, GroupSecretParams}, }; use presage::model::groups::Group; use presage::store::Thread; @@ -230,6 +231,62 @@ impl Channel { self.manager().send_session_reset(uuid, ts).await } + /// Send a typing indicator (started/stopped) to the channel. + /// + /// Returns `Ok(())` without sending if the user has disabled the + /// `send-typing-indicators` setting or the channel has no resolvable peer. + pub async fn send_typing(&self, started: bool) -> Result<(), ApplicationError> { + // Note-to-self has no useful peer to inform, and routing the + // event through `send_message(self_uuid, …)` would fan it out to + // every other linked device on the account where flare's own + // receive path lights up an "is typing" indicator on its copy of + // Note-to-self. + if self.is_self() { + return Ok(()); + } + let manager = self.manager(); + if !manager.settings().boolean("send-typing-indicators") { + return Ok(()); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() as u64; + + let action = if started { + TypingAction::Started + } else { + TypingAction::Stopped + }; + + let group_id = self + .group_context() + .and_then(|c| c.master_key) + .and_then(|k| <[u8; 32]>::try_from(k).ok()) + .map(|master_key| { + GroupSecretParams::derive_from_master_key(GroupMasterKey::new(master_key)) + .get_group_identifier() + .to_vec() + }); + + let typing = TypingMessage { + timestamp: Some(timestamp), + action: Some(action as i32), + group_id: group_id.clone(), + }; + + if let Some(group_master_key) = self.group_context().and_then(|c| c.master_key) { + manager + .send_message_to_group(group_master_key, typing, timestamp) + .await?; + } else if let Some(uuid) = self.uuid() { + manager.send_message(uuid, typing, timestamp).await?; + } + + Ok(()) + } + /// Register a new message with the channel. /// This does the following (based on the type of message): /// - Add a quote to the message if needed. diff --git a/src/backend/manager.rs b/src/backend/manager.rs index c25fba0..eaa41e0 100644 --- a/src/backend/manager.rs +++ b/src/backend/manager.rs @@ -8,7 +8,7 @@ use libsignal_service::protocol::DeviceId; use libsignal_service::{ Profile, content::ContentBody, - proto::{AttachmentPointer, DataMessage, GroupContextV2}, + proto::{AttachmentPointer, GroupContextV2}, protocol::ServiceId, sender::{AttachmentSpec, AttachmentUploadError}, websocket::account::DeviceInfo, @@ -490,20 +490,42 @@ impl Manager { Thread::Contact(uuid) }; + // Fast path: return the cached channel if we already know it. + // Without this, callers that arrive after initial channel discovery + // (incoming TypingMessage routing, in particular) would receive a + // freshly-built Channel object whose property notifications never + // reach widgets bound to the cached one in the UI — typing + // indicators on both the header bar and the channel-messages view + // would silently never light up. + if let Some(cached) = self.imp().channels.borrow().get(&thread).cloned() { + return cached; + } + let contact = Contact::from_service_address(&uuid, self).await; let channel = Channel::from_contact_or_group(contact, group, self).await; channel.initialize_avatar().await; - let mut known_channels = self.imp().channels.borrow_mut(); - known_channels.entry(thread).or_insert_with(|| { - log::trace!("Got a contact from the storage"); + // Another task may have inserted the same thread while we were + // awaiting; pick whichever is already there or insert ours. + let stored = { + let mut known = self.imp().channels.borrow_mut(); + known + .entry(thread) + .or_insert_with(|| { + log::trace!("Got a contact from the storage"); + channel.clone() + }) + .clone() + }; + + if stored == channel { self.emit_by_name::<()>("channel", &[&channel]); - channel.clone() - }); + } - // No need to initialize avatar or last messages in here, will be done when initializing contacts. + // No need to initialize avatar or last messages in here, will be + // done when initializing contacts. - channel + stored } pub fn channel_from_thread(&self, thread: Thread) -> Option { @@ -737,14 +759,15 @@ impl Manager { pub(super) async fn send_message_to_group( &self, group_key: Vec, - message: DataMessage, + message: impl Into, timestamp: u64, ) -> Result<(), ApplicationError> { log::trace!("`Manager::send_message_to_group` start"); + let body = message.into(); let internal = self.internal(); let r = tspawn!(async move { internal - .send_message_to_group(group_key, message.clone(), timestamp) + .send_message_to_group(group_key, body.clone(), timestamp) .await }) .await diff --git a/src/backend/manager_thread.rs b/src/backend/manager_thread.rs index 1f6a885..cba62ae 100644 --- a/src/backend/manager_thread.rs +++ b/src/backend/manager_thread.rs @@ -21,7 +21,7 @@ use libsignal_service::{ configuration::SignalServers, content::ContentBody, prelude::{ProfileKey, Uuid, phonenumber}, - proto::{AttachmentPointer, DataMessage, GroupContextV2}, + proto::{AttachmentPointer, GroupContextV2}, protocol::ServiceId, sender::{AttachmentSpec, AttachmentUploadError}, websocket::account::DeviceInfo, @@ -65,7 +65,7 @@ enum Command { ), SendMessageToGroup( Vec, - Box, + Box, u64, oneshot::Sender>, ), @@ -353,7 +353,7 @@ impl ManagerThread { pub async fn send_message_to_group( &self, group_key: Vec, - message: DataMessage, + message: impl Into, timestamp: u64, ) -> Result<(), Error> { let (sender, receiver) = oneshot::channel(); @@ -361,7 +361,7 @@ impl ManagerThread { .clone() .send(Command::SendMessageToGroup( group_key, - Box::new(message), + Box::new(message.into()), timestamp, sender, )) diff --git a/src/backend/message/mod.rs b/src/backend/message/mod.rs index 11ccd7c..74952ac 100644 --- a/src/backend/message/mod.rs +++ b/src/backend/message/mod.rs @@ -270,14 +270,16 @@ impl Message { // Typing messages. // Note that they are currently only implemented for contacts, this requires upstream updates to fix. ContentBody::TypingMessage(t) => { + // Both group and contact branches stay cache-only: we only + // surface typing for conversations the user already knows + // about. Going through `channel_from_uuid_or_group` here + // would mint a new Channel object on the first typing + // event from a stranger and add them to the sidebar with + // no actual messages. let channel = if let Some(id) = &t.group_id { manager.channel_from_group_id(id) } else { - Some( - manager - .channel_from_uuid_or_group(metadata.sender, &None) - .await, - ) + manager.channel_from_thread(presage::store::Thread::Contact(metadata.sender)) }; let Some(channel) = channel else { diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs index 0e8ae4e..c6684fc 100644 --- a/src/gui/channel_messages.rs +++ b/src/gui/channel_messages.rs @@ -5,6 +5,16 @@ use crate::ApplicationError; const MESSAGES_REQUEST_LOAD: usize = 10; +/// Re-send the `Started` typing event at this interval so the receiver +/// does not let the indicator expire while the user keeps composing. +/// Must stay strictly below `TYPING_NOTIFICATION_DURATION_SECONDS` in +/// `crate::backend::channel`. +const TYPING_REFRESH_SECONDS: u32 = 8; + +/// Send `Stopped` if no buffer change has happened in this many seconds. +/// Mirrors how Signal apps treat composition pauses as the end of typing. +const TYPING_IDLE_SECONDS: u32 = 5; + glib::wrapper! { /// [ChannelMessages] is the right pane displaying the list of messages and the entry-bar. pub struct ChannelMessages(ObjectSubclass) @@ -103,6 +113,200 @@ impl ChannelMessages { )); } + /// Connect the `show-typing-indicators` setting so the typing indicator + /// updates immediately when the user toggles the preference. + fn setup_typing_settings(&self) { + self.manager().settings().connect_changed( + Some("show-typing-indicators"), + clone!( + #[weak(rename_to = s)] + self, + move |_, _| s.refresh_show_typing() + ), + ); + } + + /// Re-evaluate `show-typing` for the current channel based on the channel's + /// `is-typing` state and the user's `show-typing-indicators` setting. + fn refresh_show_typing(&self) { + // The active-channel bind runs during template init before the + // manager bind, so `self.manager()` (typed as Manager, not + // Option) would panic here. Read the manager directly so + // a null intermediate state is harmless: with no manager we don't + // know the user's preference, so default to showing the indicator. + let allowed = self + .imp() + .manager + .borrow() + .as_ref() + .is_none_or(|m| m.settings().boolean("show-typing-indicators")); + let typing = self + .active_channel() + .map(|c| c.is_typing()) + .unwrap_or(false); + self.set_show_typing(allowed && typing); + } + + /// Wire the `show-typing` property to the active channel's `is-typing`. + /// Called whenever the active channel changes. + fn setup_typing_indicator(&self) { + self.refresh_show_typing(); + + // Disconnect the handler we attached on the previous active + // channel so we don't accumulate one per channel switch. + if let Some((prev_channel, handler)) = self.imp().typing_handler.take() { + prev_channel.disconnect(handler); + } + + if let Some(channel) = self.active_channel() { + let handler = channel.connect_notify_local( + Some("is-typing"), + clone!( + #[weak(rename_to = s)] + self, + move |_, _| s.refresh_show_typing() + ), + ); + self.imp() + .typing_handler + .replace(Some((channel, handler))); + } + } + + /// Send a `Started` typing event for the active channel. + /// + /// Schedules a periodic refresh so the receiver does not let the + /// indicator expire while the user is still composing. + fn send_typing_started(&self) { + let imp = self.imp(); + let Some(channel) = self.active_channel() else { + return; + }; + if !self.manager().settings().boolean("send-typing-indicators") { + return; + } + + // Mark this channel as the current typing target so a later channel + // switch can still emit a matching `Stopped` event. + imp.typing_target.replace(Some(channel.clone())); + + // Refresh `Started` periodically while the user keeps composing. + let needs_initial = !imp.sending_typing.replace(true); + + if let Some(source) = imp.typing_refresh.borrow_mut().take() { + source.remove(); + } + let refresh = glib::timeout_add_seconds_local( + TYPING_REFRESH_SECONDS, + clone!( + #[weak(rename_to = s)] + self, + #[upgrade_or] + glib::ControlFlow::Break, + move || { + if !s.imp().sending_typing.get() { + return glib::ControlFlow::Break; + } + s.dispatch_send_typing(true); + glib::ControlFlow::Continue + } + ), + ); + imp.typing_refresh.replace(Some(refresh)); + + if needs_initial { + self.dispatch_send_typing(true); + } + } + + /// Send a `Stopped` typing event for the channel that was last targeted. + fn send_typing_stopped(&self) { + let imp = self.imp(); + if let Some(source) = imp.typing_refresh.borrow_mut().take() { + source.remove(); + } + if let Some(source) = imp.typing_idle.borrow_mut().take() { + source.remove(); + } + if !imp.sending_typing.replace(false) { + // Nothing to do — we never told anyone we were typing. + imp.typing_target.replace(None); + return; + } + let Some(channel) = imp.typing_target.replace(None) else { + return; + }; + gspawn!(async move { + if let Err(e) = channel.send_typing(false).await { + log::warn!("Failed to send `Stopped` typing event: {e}"); + } + }); + } + + /// Dispatch the actual `Started` typing event to whichever channel is + /// currently considered the typing target. + fn dispatch_send_typing(&self, started: bool) { + let Some(channel) = self.imp().typing_target.borrow().clone() else { + return; + }; + gspawn!(async move { + if let Err(e) = channel.send_typing(started).await { + log::warn!("Failed to send typing event (started={started}): {e}"); + } + }); + } + + /// Connect the text entry's buffer so we can emit `Started`/`Stopped` + /// typing events as the user composes a message. + fn setup_typing_send(&self) { + let buffer = self.imp().text_entry.buffer(); + let handler = buffer.connect_changed(clone!( + #[weak(rename_to = s)] + self, + move |buf| { + let (start, end) = buf.bounds(); + if start == end { + s.send_typing_stopped(); + return; + } + // Both the Started event and the idle-stop timer are + // outbound-typing-only behaviours; if the user has + // disabled outgoing typing, do nothing and don't churn + // a timer per keystroke. + let allowed = s + .imp() + .manager + .borrow() + .as_ref() + .is_none_or(|m| m.settings().boolean("send-typing-indicators")); + if !allowed { + return; + } + s.send_typing_started(); + s.reset_typing_idle_timer(); + } + )); + self.imp().typing_buffer_handler.replace(Some(handler)); + } + + /// Schedule a one-shot timer that sends `Stopped` if the user lets the + /// composition idle for more than `TYPING_IDLE_SECONDS` seconds. + fn reset_typing_idle_timer(&self) { + let imp = self.imp(); + if let Some(source) = imp.typing_idle.borrow_mut().take() { + source.remove(); + } + let source = glib::timeout_add_seconds_local_once( + TYPING_IDLE_SECONDS, + clone!( + #[weak(rename_to = s)] + self, + move || s.send_typing_stopped() + ), + ); + imp.typing_idle.replace(Some(source)); + } + pub async fn clear_messages(&self) -> Result<(), ApplicationError> { if let Some(channel) = self.active_channel() { channel.clear_messages().await?; @@ -165,9 +369,36 @@ pub mod imp { filling_screen: Cell, #[property(get = Self::has_attachments)] has_attachments: PhantomData, + #[property(get, set)] + show_typing: Cell, + + /// Whether we currently believe the user is composing a message in the + /// active channel and have informed the peer with a `Started` event. + pub(super) sending_typing: Cell, + /// Channel to which we last sent a `Started` typing event, kept so we + /// can send a matching `Stopped` even after the active channel changes. + pub(super) typing_target: RefCell>, + /// Periodic refresh of the `Started` typing event so it does not + /// expire on the receiver side while the user is still composing. + pub(super) typing_refresh: RefCell>, + /// One-shot timer that emits `Stopped` after a stretch of no + /// further buffer changes. + pub(super) typing_idle: RefCell>, + /// Notify handler installed on the active channel's `is-typing` + /// property so we can disconnect it before re-attaching when the + /// active channel changes. + pub(super) typing_handler: RefCell>, + /// Notify + selection-changed handlers installed on the active + /// channel by `setup_selection_listener`, kept so we can disconnect + /// them before re-attaching on the next channel change. + pub(super) selection_handlers: RefCell>, + /// Buffer change handler that drives the typing-send logic; we + /// block it while restoring a draft so loading a draft does not + /// transmit a Started typing event. + pub(super) typing_buffer_handler: RefCell>, #[property(get, set = Self::set_manager, type = Manager)] - manager: RefCell>, + pub(super) manager: RefCell>, } #[gtk::template_callbacks] @@ -181,10 +412,16 @@ pub mod imp { self.manager.replace(man); if initialized { self.obj().setup_send_on_enter(); + self.obj().setup_typing_settings(); + self.obj().setup_typing_send(); } } fn set_active_channel(&self, chan: Option) { + // Inform the previous channel we have stopped typing before we + // forget about it. + self.obj().send_typing_stopped(); + if let Some(active_chan) = self.active_channel.borrow().as_ref() { active_chan.set_property("draft", self.text_entry.text()); } @@ -195,6 +432,7 @@ pub mod imp { } self.obj().focus_input(); + self.obj().setup_typing_indicator(); } #[template_callback(function)] @@ -501,7 +739,18 @@ pub mod imp { s.obj().set_reply_message(None::); if let Some(channel) = s.active_channel.borrow().as_ref() { let draft = channel.property("draft"); + // Block the typing buffer-changed handler so + // restoring a stored draft does not transmit + // a Started typing event to the peer. + let buffer = s.text_entry.buffer(); + let handler_guard = s.typing_buffer_handler.borrow(); + if let Some(handler) = handler_guard.as_ref() { + buffer.block_signal(handler); + } s.text_entry.set_text(draft); + if let Some(handler) = handler_guard.as_ref() { + buffer.unblock_signal(handler); + } }; } ), diff --git a/src/gui/preferences_window.rs b/src/gui/preferences_window.rs index 8137af7..b2b6405 100644 --- a/src/gui/preferences_window.rs +++ b/src/gui/preferences_window.rs @@ -78,6 +78,11 @@ pub mod imp { #[template_child] row_send_on_enter: TemplateChild, + #[template_child] + row_send_typing_indicators: TemplateChild, + #[template_child] + row_show_typing_indicators: TemplateChild, + settings: Settings, } @@ -173,6 +178,22 @@ pub mod imp { .bind("send-on-enter", &self.row_send_on_enter.get(), "active") .flags(SettingsBindFlags::DEFAULT) .build(); + self.settings + .bind( + "send-typing-indicators", + &self.row_send_typing_indicators.get(), + "active", + ) + .flags(SettingsBindFlags::DEFAULT) + .build(); + self.settings + .bind( + "show-typing-indicators", + &self.row_show_typing_indicators.get(), + "active", + ) + .flags(SettingsBindFlags::DEFAULT) + .build(); } } @@ -194,6 +215,8 @@ pub mod imp { row_background: TemplateChild::default(), row_messages_selectable: TemplateChild::default(), row_send_on_enter: TemplateChild::default(), + row_send_typing_indicators: TemplateChild::default(), + row_show_typing_indicators: TemplateChild::default(), } } -- 2.53.0