flare: add patched flare-signal with five local feature patches
- patches/flare/000{1..5}-*.patch: typing indicators, formatted
messages, edited messages, multi-select with delete-for-me, and
in-channel message search. Mirror the matching commits in
~/projects/forks/flare and apply cleanly on top of upstream 0.20.4
(which is what nixpkgs ships).
- home/profiles/gui.nix: include a flare-signal override that appends
the patches via overrideAttrs. None of them touch Cargo.lock so the
cargoDeps hash stays valid; signal-desktop stays alongside it.
This commit is contained in:
732
patches/flare/0001-feat-typing-Implement-typing-indicators.patch
Normal file
732
patches/flare/0001-feat-typing-Implement-typing-indicators.patch
Normal file
@@ -0,0 +1,732 @@
|
||||
From 733ad6e63fa6408e47d87a22cf51a784f5ce103f Mon Sep 17 00:00:00 2001
|
||||
From: Simon Gardling <titaniumtown@proton.me>
|
||||
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 @@
|
||||
<summary>Send a message when the Enter-key is pressed</summary>
|
||||
</key>
|
||||
|
||||
+ <key name="show-typing-indicators" type="b">
|
||||
+ <default>true</default>
|
||||
+ <summary>Show typing indicators of other users</summary>
|
||||
+ </key>
|
||||
+ <key name="send-typing-indicators" type="b">
|
||||
+ <default>true</default>
|
||||
+ <summary>Send typing indicators while composing</summary>
|
||||
+ </key>
|
||||
+
|
||||
<key name="sort-contacts-by" type="s">
|
||||
<default>"firstname"</default>
|
||||
<summary>How to sort contacts, e.g with "firstname" or "surname"</summary>
|
||||
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 <bool>;
|
||||
+
|
||||
+ 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<Channel> {
|
||||
@@ -737,14 +759,15 @@ impl Manager {
|
||||
pub(super) async fn send_message_to_group(
|
||||
&self,
|
||||
group_key: Vec<u8>,
|
||||
- message: DataMessage,
|
||||
+ message: impl Into<ContentBody>,
|
||||
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<u8>,
|
||||
- Box<DataMessage>,
|
||||
+ Box<ContentBody>,
|
||||
u64,
|
||||
oneshot::Sender<Result<(), Error>>,
|
||||
),
|
||||
@@ -353,7 +353,7 @@ impl ManagerThread {
|
||||
pub async fn send_message_to_group(
|
||||
&self,
|
||||
group_key: Vec<u8>,
|
||||
- message: DataMessage,
|
||||
+ message: impl Into<ContentBody>,
|
||||
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<imp::ChannelMessages>)
|
||||
@@ -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<Manager>) 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<bool>,
|
||||
#[property(get = Self::has_attachments)]
|
||||
has_attachments: PhantomData<bool>,
|
||||
+ #[property(get, set)]
|
||||
+ show_typing: Cell<bool>,
|
||||
+
|
||||
+ /// 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<bool>,
|
||||
+ /// 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<Option<Channel>>,
|
||||
+ /// 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<Option<glib::SourceId>>,
|
||||
+ /// One-shot timer that emits `Stopped` after a stretch of no
|
||||
+ /// further buffer changes.
|
||||
+ pub(super) typing_idle: RefCell<Option<glib::SourceId>>,
|
||||
+ /// 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<Option<(Channel, glib::SignalHandlerId)>>,
|
||||
+ /// 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<Vec<(Channel, glib::SignalHandlerId)>>,
|
||||
+ /// 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<Option<glib::SignalHandlerId>>,
|
||||
|
||||
#[property(get, set = Self::set_manager, type = Manager)]
|
||||
- manager: RefCell<Option<Manager>>,
|
||||
+ pub(super) manager: RefCell<Option<Manager>>,
|
||||
}
|
||||
|
||||
#[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<Channel>) {
|
||||
+ // 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::<TextMessage>);
|
||||
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<adw::SwitchRow>,
|
||||
|
||||
+ #[template_child]
|
||||
+ row_send_typing_indicators: TemplateChild<adw::SwitchRow>,
|
||||
+ #[template_child]
|
||||
+ row_show_typing_indicators: TemplateChild<adw::SwitchRow>,
|
||||
+
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user