Browse Source

message-toolbar: Keep track of the composer state between room switches

merge-requests/1716/head
Kévin Commaille 2 years ago
parent
commit
59e4555215
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 67
      Cargo.lock
  2. 4
      Cargo.toml
  3. 2
      src/prelude.rs
  4. 13
      src/session/model/room/event/mod.rs
  5. 57
      src/session/view/content/room_history/item_row.rs
  6. 272
      src/session/view/content/room_history/message_row/content.rs
  7. 53
      src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs
  8. 3
      src/session/view/content/room_history/message_toolbar/completion/member_list.rs
  9. 3
      src/session/view/content/room_history/message_toolbar/completion/room_list.rs
  10. 208
      src/session/view/content/room_history/message_toolbar/composer_state.rs
  11. 498
      src/session/view/content/room_history/message_toolbar/mod.rs
  12. 6
      src/session/view/content/room_history/message_toolbar/mod.ui
  13. 26
      src/utils/matrix.rs

67
Cargo.lock generated

@ -191,12 +191,6 @@ dependencies = [
"zbus",
]
[[package]]
name = "assert_matches2"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15832d94c458da98cac0ffa6eca52cc19c2a3c6c951058500a5ae8f01f0fdf56"
[[package]]
name = "assign"
version = "1.1.1"
@ -707,9 +701,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "779e6b7d17797c0b42023d417228c02889300190e700cb074c3438d9c541d332"
checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490"
dependencies = [
"jobserver",
"libc",
@ -743,9 +737,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
@ -2371,9 +2365,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.3.1"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d"
checksum = "c4fe55fb7a772d59a5ff1dfbff4fe0258d19b89fec4b233e75d35d5d2316badc"
dependencies = [
"bytes",
"futures-channel",
@ -2407,9 +2401,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.5"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56"
checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956"
dependencies = [
"bytes",
"futures-channel",
@ -3047,7 +3041,7 @@ dependencies = [
[[package]]
name = "matrix-sdk"
version = "0.7.1"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"anymap2",
"aquamarine",
@ -3098,7 +3092,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-base"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"as_variant",
"async-trait",
@ -3122,7 +3116,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-common"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"async-trait",
"futures-core",
@ -3144,11 +3138,10 @@ dependencies = [
[[package]]
name = "matrix-sdk-crypto"
version = "0.7.1"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"aes",
"as_variant",
"assert_matches2",
"async-trait",
"bs58",
"byteorder",
@ -3186,7 +3179,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-indexeddb"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"anyhow",
"async-trait",
@ -3214,7 +3207,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-qrcode"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"byteorder",
"qrcode",
@ -3226,7 +3219,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-sqlite"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"async-trait",
"deadpool-sqlite",
@ -3248,7 +3241,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-store-encryption"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"base64",
"blake3",
@ -3267,7 +3260,7 @@ dependencies = [
[[package]]
name = "matrix-sdk-ui"
version = "0.7.0"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=38a18c3c8e3be2951967d463ade1f6607228e6a7#38a18c3c8e3be2951967d463ade1f6607228e6a7"
source = "git+https://github.com/matrix-org/matrix-rust-sdk.git?rev=0a7184e594d1dc0c00e5d5773f206f50a6c0939a#0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
dependencies = [
"as_variant",
"async-once-cell",
@ -3416,9 +3409,9 @@ dependencies = [
[[package]]
name = "nix"
version = "0.28.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
@ -4741,9 +4734,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.118"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4"
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
dependencies = [
"itoa",
"ryu",
@ -5967,9 +5960,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "4.3.0"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23915fcb26e7a9a9dc05fd93a9870d336d6d032cd7e8cebf1c5c37666489fdd5"
checksum = "851238c133804e0aa888edf4a0229481c753544ca12a60fd1c3230c8a500fe40"
dependencies = [
"async-broadcast",
"async-process",
@ -5981,7 +5974,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix 0.28.0",
"nix 0.29.0",
"ordered-stream",
"rand",
"serde",
@ -6000,9 +5993,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "4.3.0"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bcca0b586d2f8589da32347b4784ba424c4891ed86aa5b50d5e88f6b2c4f5d"
checksum = "8d5a3f12c20bd473be3194af6b49d50d7bb804ef3192dc70eddedb26b85d9da7"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@ -6088,9 +6081,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "4.1.1"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa6d31a02fbfb602bfde791de7fedeb9c2c18115b3d00f3a36e489f46ffbbc7"
checksum = "1724a2b330760dc7d2a8402d841119dc869ef120b139d29862d6980e9c75bfc9"
dependencies = [
"endi",
"enumflags2",
@ -6102,9 +6095,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "4.1.1"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "642bf1b6b6d527988b3e8193d20969d53700a36eac734d21ae6639db168701c8"
checksum = "55025a7a518ad14518fb243559c058a2e5b848b015e31f1d90414f36e3317859"
dependencies = [
"proc-macro-crate",
"proc-macro2",

4
Cargo.toml

@ -63,7 +63,7 @@ sourceview = { package = "sourceview5", version = "0.8" }
[dependencies.matrix-sdk]
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "38a18c3c8e3be2951967d463ade1f6607228e6a7"
rev = "0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
features = [
"socks",
"sso-login",
@ -74,7 +74,7 @@ features = [
[dependencies.matrix-sdk-ui]
git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "38a18c3c8e3be2951967d463ade1f6607228e6a7"
rev = "0a7184e594d1dc0c00e5d5773f206f50a6c0939a"
default-features = false
features = ["e2e-encryption", "native-tls"]

2
src/prelude.rs

@ -5,7 +5,7 @@ pub use crate::{
session_list::SessionInfoExt,
user_facing_error::UserFacingError,
utils::{
matrix::TimelineItemContentExt,
matrix::AtMentionExt,
string::{StrExt, StrMutExt},
LocationExt,
},

13
src/session/model/room/event/mod.rs

@ -501,6 +501,19 @@ impl Event {
}
}
/// Whether the given key matches this `Event`.
///
/// The result can be different from comparing two `EventKey`s because an
/// event can have a transaction ID and an event ID.
pub fn matches_key(&self, key: &EventKey) -> bool {
let item_ref = self.imp().item.borrow();
let item = item_ref.as_ref().unwrap();
match key {
EventKey::TransactionId(txn_id) => item.transaction_id().is_some_and(|id| id == txn_id),
EventKey::EventId(event_id) => item.event_id().is_some_and(|id| id == event_id),
}
}
/// The event ID of this `Event`, if it has been received from the server.
pub fn event_id(&self) -> Option<OwnedEventId> {
match self.key() {

57
src/session/view/content/room_history/item_row.rs

@ -12,11 +12,11 @@ use crate::{
matrix_caption,
prelude::*,
session::{
model::{Event, MessageState, TimelineItem, VirtualItem, VirtualItemKind},
view::EventDetailsDialog,
model::{Event, EventKey, MessageState, TimelineItem, VirtualItem, VirtualItemKind},
view::{content::room_history::message_toolbar::ComposerState, EventDetailsDialog},
},
spawn, toast,
utils::media::save_to_file,
utils::{media::save_to_file, BoundObjectWeakRef},
};
mod imp {
@ -31,6 +31,7 @@ mod imp {
#[property(get, set = Self::set_room_history, construct_only)]
pub room_history: glib::WeakRef<RoomHistory>,
pub message_toolbar_handler: RefCell<Option<glib::SignalHandlerId>>,
pub composer_state: BoundObjectWeakRef<ComposerState>,
/// The [`TimelineItem`] presented by this row.
#[property(get, set = Self::set_item, explicit_notify, nullable)]
pub item: RefCell<Option<TimelineItem>>,
@ -81,8 +82,8 @@ mod imp {
binding.unbind();
}
if let Some(room_history) = self.room_history.upgrade() {
if let Some(handler) = self.message_toolbar_handler.take() {
if let Some(handler) = self.message_toolbar_handler.take() {
if let Some(room_history) = self.room_history.upgrade() {
room_history.message_toolbar().disconnect(handler);
}
}
@ -181,21 +182,43 @@ mod imp {
impl ItemRow {
/// Set the ancestor room history of this row.
fn set_room_history(&self, room_history: RoomHistory) {
let obj = self.obj();
self.room_history.set(Some(&room_history));
let related_event_handler = room_history
.message_toolbar()
.connect_related_event_notify(clone!(
#[weak]
obj,
let message_toolbar = room_history.message_toolbar();
let message_toolbar_handler =
message_toolbar.connect_current_composer_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |message_toolbar| {
obj.update_for_related_event(message_toolbar.related_event());
imp.watch_related_event(&message_toolbar.current_composer_state());
}
));
self.message_toolbar_handler
.replace(Some(related_event_handler));
.replace(Some(message_toolbar_handler));
self.watch_related_event(&message_toolbar.current_composer_state());
}
/// Watch the related event for given current composer state of the
/// toolbar.
fn watch_related_event(&self, composer_state: &ComposerState) {
let obj = self.obj();
self.composer_state.disconnect_signals();
let composer_state_handler = composer_state.connect_related_to_changed(clone!(
#[weak]
obj,
move |composer_state| {
obj.update_for_related_event(
composer_state.related_to().map(|i| i.key()).as_ref(),
);
}
));
self.composer_state
.set(composer_state, vec![composer_state_handler]);
obj.update_for_related_event(composer_state.related_to().map(|i| i.key()).as_ref());
}
/// Set the [`TimelineItem`] presented by this row.
@ -472,11 +495,11 @@ impl ItemRow {
emoji_chooser.popup();
}
/// Update this row for the currently related event.
fn update_for_related_event(&self, related_event: Option<Event>) {
/// Update this row for the related event with the given key.
fn update_for_related_event(&self, related_event_id: Option<&EventKey>) {
let event = self.item().and_downcast::<Event>();
if event.is_some() && event == related_event {
if event.is_some_and(|event| related_event_id.is_some_and(|key| event.matches_key(key))) {
self.add_css_class("selected");
} else {
self.remove_css_class("selected");

272
src/session/view/content/room_history/message_row/content.rs

@ -1,7 +1,9 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gdk, glib, glib::clone};
use matrix_sdk_ui::timeline::{TimelineDetails, TimelineItemContent};
use matrix_sdk_ui::timeline::{
Message, RepliedToInfo, ReplyContent, TimelineDetails, TimelineItemContent,
};
use ruma::events::room::message::MessageType;
use tracing::{error, warn};
@ -11,7 +13,7 @@ use super::{
};
use crate::{
prelude::*,
session::model::{content_can_show_header, Event, Member, Room},
session::model::{content_can_show_header, Event, Member},
spawn,
};
@ -107,8 +109,8 @@ impl MessageContent {
child.downcast::<MessageMedia>().ok()
}
/// Update this widget to present the given `Event`.
pub fn update_for_event(&self, event: &Event) {
let room = event.room();
let detect_at_room = event.can_contain_at_room() && event.sender().can_notify_room();
let format = self.format();
@ -138,7 +140,8 @@ impl MessageContent {
TimelineDetails::Ready(replied_to_event) => {
// We should have a strong reference to the list in the RoomHistory so we
// can use `get_or_create_members()`.
let replied_to_sender = room
let replied_to_sender = event
.room()
.get_or_create_members()
.get_or_create(replied_to_event.sender().to_owned());
let replied_to_content = replied_to_event.content();
@ -155,7 +158,6 @@ impl MessageContent {
replied_to_content.clone(),
ContentFormat::Compact,
replied_to_sender,
&room,
replied_to_detect_at_room,
);
build_content(
@ -163,7 +165,6 @@ impl MessageContent {
event.content(),
ContentFormat::Natural,
event.sender(),
&room,
detect_at_room,
);
self.set_child(Some(&reply));
@ -180,11 +181,21 @@ impl MessageContent {
event.content(),
format,
event.sender(),
&room,
detect_at_room,
);
}
/// Update this widget to present the given related event.
pub fn update_for_related_event(&self, info: RepliedToInfo, sender: Member) {
let ReplyContent::Message(message) = info.content() else {
return;
};
let detect_at_room = message.can_contain_at_room() && sender.can_notify_room();
build_message_content(self, message, self.format(), sender, detect_at_room);
}
/// Get the texture displayed by this widget, if any.
pub fn texture(&self) -> Option<gdk::Texture> {
self.media_widget()?.texture()
@ -197,9 +208,70 @@ fn build_content(
content: TimelineItemContent,
format: ContentFormat,
sender: Member,
room: &Room,
detect_at_room: bool,
) {
let room = sender.room();
let Some(session) = room.session() else {
return;
};
match content {
TimelineItemContent::Message(message) => {
build_message_content(parent, &message, format, sender, detect_at_room)
}
TimelineItemContent::Sticker(sticker) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageMedia>() {
child
} else {
let child = MessageMedia::new();
parent.set_child(Some(&child));
child
};
child.sticker(sticker.content().clone(), &session, format);
}
TimelineItemContent::UnableToDecrypt(_) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("Could not decrypt this message, decryption will be retried once the keys are available."), format);
}
TimelineItemContent::RedactedMessage => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("This message was removed."), format);
}
content => {
warn!("Unsupported event content: {content:?}");
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("Unsupported event"), format);
}
}
}
/// Build the content widget of the given message event as a child of `parent`.
fn build_message_content(
parent: &impl IsA<adw::Bin>,
message: &Message,
format: ContentFormat,
sender: Member,
detect_at_room: bool,
) {
let room = sender.room();
let Some(session) = room.session() else {
return;
};
@ -224,7 +296,7 @@ fn build_content(
caption_widget.set_caption(
caption,
formatted_caption,
room,
&room,
format,
detect_at_room,
);
@ -248,124 +320,51 @@ fn build_content(
}};
}
match content {
TimelineItemContent::Message(message) => match message.msgtype() {
MessageType::Audio(message) => {
let (child, filename) =
with_caption!(parent, message, MessageAudio, Some(mime::AUDIO));
child.audio(message.clone(), filename, &session, format);
}
MessageType::Emote(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_emote(
message.formatted.clone(),
message.body.clone(),
sender,
room,
format,
detect_at_room,
);
}
MessageType::File(message) => {
let (child, filename) = with_caption!(parent, message, MessageFile, None);
match message.msgtype() {
MessageType::Audio(message) => {
let (child, filename) = with_caption!(parent, message, MessageAudio, Some(mime::AUDIO));
child.set_filename(Some(filename));
child.set_format(format);
}
MessageType::Image(message) => {
let (child, filename) =
with_caption!(parent, message, MessageMedia, Some(mime::IMAGE));
child.audio(message.clone(), filename, &session, format);
}
MessageType::Emote(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_emote(
message.formatted.clone(),
message.body.clone(),
sender,
&room,
format,
detect_at_room,
);
}
MessageType::File(message) => {
let (child, filename) = with_caption!(parent, message, MessageFile, None);
child.image(message.clone(), filename, &session, format);
}
MessageType::Location(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageLocation>() {
child
} else {
let child = MessageLocation::new();
parent.set_child(Some(&child));
child
};
child.set_geo_uri(&message.geo_uri, format);
}
MessageType::Notice(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_markup(
message.formatted.clone(),
message.body.clone(),
room,
format,
detect_at_room,
);
}
MessageType::ServerNotice(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_plain_text(message.body.clone(), format);
}
MessageType::Text(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_markup(
message.formatted.clone(),
message.body.clone(),
room,
format,
detect_at_room,
);
}
MessageType::Video(message) => {
let (child, filename) =
with_caption!(parent, message, MessageMedia, Some(mime::VIDEO));
child.set_filename(Some(filename));
child.set_format(format);
}
MessageType::Image(message) => {
let (child, filename) = with_caption!(parent, message, MessageMedia, Some(mime::IMAGE));
child.video(message.clone(), filename, &session, format);
}
msgtype => {
warn!("Event not supported: {msgtype:?}");
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("Unsupported event"), format);
}
},
TimelineItemContent::Sticker(sticker) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageMedia>() {
child.image(message.clone(), filename, &session, format);
}
MessageType::Location(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageLocation>() {
child
} else {
let child = MessageMedia::new();
let child = MessageLocation::new();
parent.set_child(Some(&child));
child
};
child.sticker(sticker.content().clone(), &session, format);
child.set_geo_uri(&message.geo_uri, format);
}
TimelineItemContent::UnableToDecrypt(_) => {
MessageType::Notice(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
@ -373,9 +372,15 @@ fn build_content(
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("Could not decrypt this message, decryption will be retried once the keys are available."), format);
child.with_markup(
message.formatted.clone(),
message.body.clone(),
&room,
format,
detect_at_room,
);
}
TimelineItemContent::RedactedMessage => {
MessageType::ServerNotice(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
@ -383,10 +388,31 @@ fn build_content(
parent.set_child(Some(&child));
child
};
child.with_plain_text(gettext("This message was removed."), format);
child.with_plain_text(message.body.clone(), format);
}
content => {
warn!("Unsupported event content: {content:?}");
MessageType::Text(message) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {
let child = MessageText::new();
parent.set_child(Some(&child));
child
};
child.with_markup(
message.formatted.clone(),
message.body.clone(),
&room,
format,
detect_at_room,
);
}
MessageType::Video(message) => {
let (child, filename) = with_caption!(parent, message, MessageMedia, Some(mime::VIDEO));
child.video(message.clone(), filename, &session, format);
}
msgtype => {
warn!("Event not supported: {msgtype:?}");
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
child
} else {

53
src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs

@ -6,7 +6,8 @@ use secular::normalized_lower_lay_string;
use super::{CompletionMemberList, CompletionRoomList};
use crate::{
components::{Pill, PillSource, PillSourceRow},
session::model::Room,
session::{model::Room, view::content::room_history::message_toolbar::MessageToolbar},
utils::BoundObject,
};
/// The maximum number of rows presented in the popover.
@ -50,8 +51,8 @@ mod imp {
pub current_word: RefCell<Option<(gtk::TextIter, gtk::TextIter, SearchTerm)>>,
/// Whether the popover is inhibited for the current word.
pub inhibit: Cell<bool>,
/// The buffer to complete with its cursor position signal handler ID.
pub buffer_handler: RefCell<Option<(gtk::TextBuffer, glib::SignalHandlerId)>>,
/// The buffer to autocomplete.
pub buffer: BoundObject<gtk::TextBuffer>,
}
#[glib::object_subclass]
@ -81,22 +82,18 @@ mod imp {
obj.connect_parent_notify(|obj| {
let imp = obj.imp();
if let Some((buffer, handler_id)) = imp.buffer_handler.take() {
buffer.disconnect(handler_id);
}
imp.update_buffer();
if obj.parent().is_some() {
let view = obj.view();
let buffer = view.buffer();
let handler_id = buffer.connect_cursor_position_notify(clone!(
view.connect_buffer_notify(clone!(
#[weak]
obj,
imp,
move |_| {
obj.update_completion(false);
imp.update_buffer();
}
));
imp.buffer_handler.replace(Some((buffer, handler_id)));
let key_events = gtk::EventControllerKey::new();
key_events.connect_key_pressed(clone!(
@ -197,6 +194,35 @@ mod imp {
fn view(&self) -> gtk::TextView {
self.obj().parent().and_downcast::<gtk::TextView>().unwrap()
}
/// The ancestor `MessageToolbar`.
pub(super) fn message_toolbar(&self) -> MessageToolbar {
self.obj()
.ancestor(MessageToolbar::static_type())
.and_downcast::<MessageToolbar>()
.unwrap()
}
/// Handle a change of buffer.
fn update_buffer(&self) {
let obj = self.obj();
self.buffer.disconnect_signals();
if obj.parent().is_some() {
let buffer = self.view().buffer();
let handler_id = buffer.connect_cursor_position_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_completion(false);
}
));
self.buffer.set(buffer, vec![handler_id]);
obj.update_completion(false);
}
}
}
}
@ -610,6 +636,9 @@ impl CompletionPopover {
};
let pill = Pill::new(&source);
view.add_child_at_anchor(&pill, &anchor);
imp.message_toolbar()
.current_composer_state()
.add_widget(pill, anchor);
self.popdown();
self.select_row_at_index(None);

3
src/session/view/content/room_history/message_toolbar/completion/member_list.rs

@ -159,9 +159,6 @@ mod imp {
}
}
impl WidgetImpl for CompletionMemberList {}
impl PopoverImpl for CompletionMemberList {}
impl CompletionMemberList {
/// The room members used for completion.
fn members(&self) -> Option<MemberList> {

3
src/session/view/content/room_history/message_toolbar/completion/room_list.rs

@ -119,9 +119,6 @@ mod imp {
}
}
impl WidgetImpl for CompletionRoomList {}
impl PopoverImpl for CompletionRoomList {}
impl CompletionRoomList {
/// The rooms used for completion.
fn rooms(&self) -> Option<RoomList> {

208
src/session/view/content/room_history/message_toolbar/composer_state.rs

@ -0,0 +1,208 @@
use gtk::{
glib,
glib::{clone, closure_local},
prelude::*,
subclass::prelude::*,
};
use matrix_sdk_ui::timeline::{EditInfo, RepliedToInfo, TimelineEventItemId};
use ruma::OwnedRoomId;
use sourceview::prelude::*;
use crate::session::model::EventKey;
mod imp {
use std::{
cell::{OnceCell, RefCell},
marker::PhantomData,
};
use glib::subclass::Signal;
use once_cell::sync::Lazy;
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::ComposerState)]
pub struct ComposerState {
/// The room ID associated with this state.
pub room_id: OnceCell<OwnedRoomId>,
/// The buffer of this state.
#[property(get)]
pub buffer: sourceview::Buffer,
/// The relation of this state.
pub related_to: RefCell<Option<RelationInfo>>,
/// Whether this state has a relation.
#[property(get = Self::has_relation)]
pub has_relation: PhantomData<bool>,
/// The widgets of this state.
///
/// These are the widgets inserted in the composer.
pub widgets: RefCell<Vec<(gtk::Widget, gtk::TextChildAnchor)>>,
}
#[glib::object_subclass]
impl ObjectSubclass for ComposerState {
const NAME: &'static str = "ContentComposerState";
type Type = super::ComposerState;
}
#[glib::derived_properties]
impl ObjectImpl for ComposerState {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> =
Lazy::new(|| vec![Signal::builder("related-to-changed").build()]);
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
crate::utils::sourceview::setup_style_scheme(&self.buffer);
// Markdown highlighting.
let md_lang = sourceview::LanguageManager::default().language("markdown");
self.buffer.set_language(md_lang.as_ref());
self.buffer.connect_delete_range(clone!(
#[weak(rename_to = imp)]
self,
move |_, _, _| {
imp.widgets
.borrow_mut()
.retain(|(_w, anchor)| !anchor.is_deleted());
}
));
}
}
impl ComposerState {
/// Whether this state has a relation.
fn has_relation(&self) -> bool {
self.related_to.borrow().is_some()
}
}
}
glib::wrapper! {
/// The composer state for a room.
///
/// This allows to save and restore the composer state between room changes. It keeps track of the related event and restores the state of the composer's `GtkSourceView`.
pub struct ComposerState(ObjectSubclass<imp::ComposerState>);
}
impl ComposerState {
/// Create a new empty `ComposerState` for the given room ID.
pub fn new(room_id: Option<OwnedRoomId>) -> Self {
let obj = glib::Object::new::<Self>();
if let Some(room_id) = room_id {
obj.imp()
.room_id
.set(room_id)
.expect("OnceCell is not initialized yet");
}
obj
}
/// The room ID associated with this state.
pub fn room_id(&self) -> Option<&OwnedRoomId> {
self.imp().room_id.get()
}
/// Attach the buffer of this state to the given view.
pub fn attach_to_view(&self, view: &sourceview::View) {
let imp = self.imp();
view.set_buffer(Some(&imp.buffer));
for (widget, anchor) in &*imp.widgets.borrow() {
view.add_child_at_anchor(widget, anchor);
}
}
/// Clear this state.
pub fn clear(&self) {
self.set_related_to(None);
let imp = self.imp();
imp.buffer.set_text("");
imp.widgets.borrow_mut().clear();
}
/// The relation to send with the current message.
pub fn related_to(&self) -> Option<RelationInfo> {
self.imp().related_to.borrow().clone()
}
/// Set the relation to send with the current message.
pub fn set_related_to(&self, related_to: Option<RelationInfo>) {
let imp = self.imp();
let had_relation = self.has_relation();
if imp
.related_to
.borrow()
.as_ref()
.is_some_and(|r| matches!(r, RelationInfo::Edit(_)))
{
// The user aborted the edit or the edit is done, clean up the entry.
imp.buffer.set_text("");
}
imp.related_to.replace(related_to);
if self.has_relation() != had_relation {
self.notify_has_relation();
}
self.emit_by_name::<()>("related-to-changed", &[]);
}
/// Add the given widget and anchor to this state.
pub fn add_widget(&self, widget: impl IsA<gtk::Widget>, anchor: gtk::TextChildAnchor) {
self.imp()
.widgets
.borrow_mut()
.push((widget.upcast(), anchor));
}
/// Connect to the signal emitted when the relation changed.
pub fn connect_related_to_changed<F: Fn(&Self) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"related-to-changed",
true,
closure_local!(move |obj: Self| {
f(&obj);
}),
)
}
}
/// The possible relations to send with a message.
#[derive(Debug, Clone)]
pub enum RelationInfo {
/// Send a reply with the given replied to info.
Reply(RepliedToInfo),
/// Send an edit with the given edit info.
Edit(EditInfo),
}
impl RelationInfo {
/// The unique key of the related event.
pub fn key(&self) -> EventKey {
match self {
RelationInfo::Reply(info) => EventKey::EventId(info.event_id().to_owned()),
RelationInfo::Edit(info) => match info.id() {
TimelineEventItemId::TransactionId(txn_id) => {
EventKey::TransactionId(txn_id.clone())
}
TimelineEventItemId::EventId(event_id) => EventKey::EventId(event_id.clone()),
},
}
}
}

498
src/session/view/content/room_history/message_toolbar/mod.rs

@ -24,14 +24,15 @@ use ruma::{
Mentions,
},
matrix_uri::MatrixId,
OwnedUserId,
OwnedRoomId, OwnedUserId,
};
use sourceview::prelude::*;
use tracing::{debug, error, warn};
mod attachment_dialog;
mod completion;
mod composer_state;
pub use self::composer_state::{ComposerState, RelationInfo};
use self::{attachment_dialog::AttachmentDialog, completion::CompletionPopover};
use super::message_row::MessageContent;
use crate::{
@ -48,18 +49,12 @@ use crate::{
},
};
#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
#[repr(i32)]
#[enum_type(name = "RelatedEventType")]
pub enum RelatedEventType {
#[default]
None = 0,
Reply = 1,
Edit = 2,
}
mod imp {
use std::cell::{Cell, RefCell};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
marker::PhantomData,
};
use glib::subclass::InitializingObject;
@ -88,12 +83,15 @@ mod imp {
pub related_event_header: TemplateChild<LabelWithWidgets>,
#[template_child]
pub related_event_content: TemplateChild<MessageContent>,
/// The type of related event of the composer.
#[property(get, builder(RelatedEventType::default()))]
pub related_event_type: Cell<RelatedEventType>,
/// The related event of the composer.
#[property(get)]
pub related_event: RefCell<Option<Event>>,
/// The current composer state.
#[property(get = Self::current_composer_state)]
pub current_composer_state: PhantomData<ComposerState>,
composer_state_handler: RefCell<Option<glib::SignalHandlerId>>,
buffer_handlers: RefCell<Option<(glib::SignalHandlerId, glib::Binding)>>,
/// The composer states, per-room.
///
/// The fallback composer state has the `None` key.
pub composer_states: RefCell<HashMap<Option<OwnedRoomId>, ComposerState>>,
}
#[glib::object_subclass]
@ -139,11 +137,9 @@ mod imp {
klass.install_property_action("message-toolbar.markdown", "markdown-enabled");
klass.install_action(
"message-toolbar.clear-related-event",
None,
|widget, _, _| widget.clear_related_event(),
);
klass.install_action("message-toolbar.clear-related-event", None, |obj, _, _| {
obj.current_composer_state().set_related_to(None);
});
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -223,9 +219,9 @@ mod imp {
glib::Propagation::Stop
} else if modifier.is_empty()
&& key == gdk::Key::Escape
&& obj.related_event_type() != RelatedEventType::None
&& obj.current_composer_state().has_relation()
{
obj.clear_related_event();
obj.current_composer_state().set_related_to(None);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
@ -234,36 +230,7 @@ mod imp {
));
self.message_entry.add_controller(key_events);
let buffer = self
.message_entry
.buffer()
.downcast::<sourceview::Buffer>()
.unwrap();
crate::utils::sourceview::setup_style_scheme(&buffer);
// Actions on changes in message entry.
buffer.connect_text_notify(clone!(
#[weak]
obj,
move |buffer| {
let (start_iter, end_iter) = buffer.bounds();
let is_empty = start_iter == end_iter;
obj.action_set_enabled("message-toolbar.send-text-message", !is_empty);
obj.send_typing_notification(!is_empty);
}
));
let (start_iter, end_iter) = buffer.bounds();
obj.action_set_enabled("message-toolbar.send-text-message", start_iter != end_iter);
// Markdown highlighting.
let md_lang = sourceview::LanguageManager::default().language("markdown");
buffer.set_language(md_lang.as_ref());
obj.bind_property("markdown-enabled", &buffer, "highlight-syntax")
.sync_create()
.build();
let settings = Application::default().settings();
settings
.bind("markdown-enabled", &*obj, "markdown-enabled")
@ -303,12 +270,11 @@ mod imp {
}
let obj = self.obj();
if let Some(room) = old_room {
if let Some(room) = &old_room {
if let Some(handler) = self.can_send_message_handler.take() {
room.permissions().disconnect(handler);
}
}
obj.clear_related_event();
if let Some(room) = &room {
let can_send_message_handler =
@ -329,6 +295,7 @@ mod imp {
self.message_entry.grab_focus();
obj.notify_room();
self.update_current_composer_state(old_room.as_ref());
}
/// Whether our own user can send a message in the current room.
@ -347,6 +314,216 @@ mod imp {
};
self.main_stack.set_visible_child_name(page);
}
/// Get the current composer state.
fn current_composer_state(&self) -> ComposerState {
let room = self.room.upgrade();
self.composer_state(room.as_ref())
}
/// Get the composer state for the given room.
///
/// If the composer state doesn't exist, it is created.
fn composer_state(&self, room: Option<&Room>) -> ComposerState {
self.composer_states
.borrow_mut()
.entry(room.map(|r| r.room_id().to_owned()))
.or_insert_with_key(|room_id| ComposerState::new(room_id.clone()))
.clone()
}
/// Update the current composer state.
fn update_current_composer_state(&self, old_room: Option<&Room>) {
if let Some(handler) = self.composer_state_handler.take() {
let old_composer_state = self.composer_state(old_room);
old_composer_state.disconnect(handler);
}
if let Some((handler, binding)) = self.buffer_handlers.take() {
let prev_buffer = self.message_entry.buffer();
prev_buffer.disconnect(handler);
binding.unbind();
}
let composer_state = self.current_composer_state();
let buffer = composer_state.buffer();
let obj = self.obj();
composer_state.attach_to_view(&self.message_entry);
// Actions on changes in message entry.
let text_notify_handler = buffer.connect_text_notify(clone!(
#[weak]
obj,
move |buffer| {
let (start_iter, end_iter) = buffer.bounds();
let is_empty = start_iter == end_iter;
obj.action_set_enabled("message-toolbar.send-text-message", !is_empty);
obj.send_typing_notification(!is_empty);
}
));
let (start_iter, end_iter) = buffer.bounds();
obj.action_set_enabled("message-toolbar.send-text-message", start_iter != end_iter);
// Markdown highlighting.
let markdown_binding = obj
.bind_property("markdown-enabled", &buffer, "highlight-syntax")
.sync_create()
.build();
self.buffer_handlers
.replace(Some((text_notify_handler, markdown_binding)));
// Related event.
let composer_state_handler = composer_state.connect_related_to_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_related_event();
}
));
self.composer_state_handler
.replace(Some(composer_state_handler));
self.update_related_event();
obj.notify_current_composer_state();
}
/// Update the displayed related event for the current state.
fn update_related_event(&self) {
let composer_state = self.current_composer_state();
match composer_state.related_to() {
Some(RelationInfo::Reply(info)) => {
self.update_for_reply(info);
}
Some(RelationInfo::Edit(info)) => {
self.update_for_edit(info);
}
None => {}
}
}
// Update the displayed related event for the given reply.
fn update_for_reply(&self, info: RepliedToInfo) {
let Some(room) = self.room.upgrade() else {
return;
};
let sender = room
.get_or_create_members()
.get_or_create(info.sender().to_owned());
self.related_event_header
.set_widgets(vec![Pill::new(&sender)]);
self.related_event_header
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name. In this string, 'Reply' is a noun.
.set_label(Some(gettext_f("Reply to {user}", &[("user", "<widget>")])));
self.related_event_content
.update_for_related_event(info, sender);
self.related_event_content.set_visible(true);
}
// Update the displayed related event for the given edit.
fn update_for_edit(&self, info: EditInfo) {
let Some(room) = self.room.upgrade() else {
return;
};
// We don't support editing non-text messages.
let (text, formatted) = match info.original_message().msgtype() {
MessageType::Emote(emote) => {
(format!("/me {}", emote.body), emote.formatted.clone())
}
MessageType::Text(text) => (text.body.clone(), text.formatted.clone()),
_ => return,
};
// Try to detect rich mentions.
let mut mentions = if let Some(html) =
formatted.and_then(|f| (f.format == MessageFormat::Html).then_some(f.body))
{
let mentions = find_html_mentions(&html, &room);
let mut pos = 0;
// This is looking for the mention link's inner text in the Markdown
// so it is not super reliable: if there is other text that matches
// a user's display name in the string it might be replaced instead
// of the actual mention.
// Short of an HTML to Markdown converter, it won't be a simple task
// to locate mentions in Markdown.
mentions
.into_iter()
.filter_map(|(pill, s)| {
text[pos..].find(s.as_ref()).map(|index| {
let start = pos + index;
let end = start + s.len();
pos = end;
DetectedMention { pill, start, end }
})
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
// Try to detect `@room` mentions.
let can_contain_at_room = info
.original_message()
.mentions()
.map(|m| m.room)
.unwrap_or(true);
if room.permissions().can_notify_room() && can_contain_at_room {
if let Some(start) = find_at_room(&text) {
let pill = room.at_room().to_pill();
let end = start + AT_ROOM.len();
mentions.push(DetectedMention { pill, start, end });
// Make sure the list is sorted.
mentions.sort_by(|lhs, rhs| lhs.start.cmp(&rhs.start));
}
}
self.related_event_header.set_widgets::<gtk::Widget>(vec![]);
self.related_event_header
// Translators: In this string, 'Edit' is a noun.
.set_label(Some(pgettext("room-history", "Edit")));
self.related_event_content.set_visible(false);
let view = &*self.message_entry;
let buffer = view.buffer();
let composer_state = self.current_composer_state();
if mentions.is_empty() {
buffer.set_text(&text);
} else {
// Place the pills instead of the text at the appropriate places in
// the GtkSourceView.
buffer.set_text("");
let mut pos = 0;
let mut iter = buffer.iter_at_offset(0);
for DetectedMention { pill, start, end } in mentions {
if pos != start {
buffer.insert(&mut iter, &text[pos..start]);
}
let anchor = buffer.create_child_anchor(&mut iter);
view.add_child_at_anchor(&pill, &anchor);
composer_state.add_widget(pill, anchor);
pos = end;
}
if pos != text.len() {
buffer.insert(&mut iter, &text[pos..])
}
}
}
}
}
@ -375,50 +552,11 @@ impl MessageToolbar {
let pill = member.to_pill();
view.add_child_at_anchor(&pill, &anchor);
self.current_composer_state().add_widget(pill, anchor);
view.grab_focus();
}
/// Set the type of related event of the composer.
fn set_related_event_type(&self, related_type: RelatedEventType) {
if self.related_event_type() == related_type {
return;
}
self.imp().related_event_type.set(related_type);
self.notify_related_event_type();
}
/// Set the related event of the composer.
fn set_related_event(&self, event: Option<Event>) {
// We shouldn't reply to events that are not sent yet.
if let Some(event) = &event {
if event.event_id().is_none() {
return;
}
}
let prev_event = self.related_event();
if prev_event == event {
return;
}
self.imp().related_event.replace(event);
self.notify_related_event();
}
/// Remove the related event.
pub fn clear_related_event(&self) {
if self.related_event_type() == RelatedEventType::Edit {
// Clean up the entry.
self.imp().message_entry.buffer().set_text("");
};
self.set_related_event(None);
self.set_related_event_type(RelatedEventType::default());
}
/// Set the event to reply to.
pub fn set_reply_to(&self, event: Event) {
let imp = self.imp();
@ -426,142 +564,35 @@ impl MessageToolbar {
return;
}
imp.related_event_header
.set_widgets(vec![Pill::new(&event.sender())]);
imp.related_event_header
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name. In this string, 'Reply' is a noun.
.set_label(Some(gettext_f("Reply to {user}", &[("user", "<widget>")])));
let Ok(info) = event.item().replied_to_info() else {
warn!("Unsupported event type for reply");
return;
};
imp.related_event_content.update_for_event(&event);
imp.related_event_content.set_visible(true);
self.current_composer_state()
.set_related_to(Some(RelationInfo::Reply(info)));
self.set_related_event_type(RelatedEventType::Reply);
self.set_related_event(Some(event));
imp.message_entry.grab_focus();
}
/// Set the event to edit.
pub fn set_edit(&self, event: Event) {
let Some(room) = self.room() else {
return;
};
let imp = self.imp();
if !imp.can_send_message() {
return;
}
// We don't support editing non-text messages.
let Some((text, formatted)) = event.message().and_then(|msg| match msg {
MessageType::Emote(emote) => Some((format!("/me {}", emote.body), emote.formatted)),
MessageType::Text(text) => Some((text.body, text.formatted)),
_ => None,
}) else {
let Ok(info) = event.item().edit_info() else {
warn!("Unsupported event type for edit");
return;
};
// Try to detect rich mentions.
let mut mentions = if let Some(html) =
formatted.and_then(|f| (f.format == MessageFormat::Html).then_some(f.body))
{
let mentions = find_html_mentions(&html, &event.room());
let mut pos = 0;
// This is looking for the mention link's inner text in the Markdown
// so it is not super reliable: if there is other text that matches
// a user's display name in the string it might be replaced instead
// of the actual mention.
// Short of an HTML to Markdown converter, it won't be a simple task
// to locate mentions in Markdown.
mentions
.into_iter()
.filter_map(|(pill, s)| {
text[pos..].find(s.as_ref()).map(|index| {
let start = pos + index;
let end = start + s.len();
pos = end;
DetectedMention { pill, start, end }
})
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
// Try to detect `@room` mentions.
if room.permissions().can_notify_room() && event.can_contain_at_room() {
if let Some(start) = find_at_room(&text) {
let pill = room.at_room().to_pill();
let end = start + AT_ROOM.len();
mentions.push(DetectedMention { pill, start, end });
// Make sure the list is sorted.
mentions.sort_by(|lhs, rhs| lhs.start.cmp(&rhs.start));
}
}
imp.related_event_header.set_widgets::<gtk::Widget>(vec![]);
imp.related_event_header
// Translators: In this string, 'Edit' is a noun.
.set_label(Some(pgettext("room-history", "Edit")));
imp.related_event_content.set_visible(false);
self.set_related_event_type(RelatedEventType::Edit);
self.set_related_event(Some(event));
let view = &*imp.message_entry;
let buffer = view.buffer();
if mentions.is_empty() {
buffer.set_text(&text);
} else {
// Place the pills instead of the text at the appropriate places in
// the GtkSourceView.
buffer.set_text("");
let mut pos = 0;
let mut iter = buffer.iter_at_offset(0);
for DetectedMention { pill, start, end } in mentions {
if pos != start {
buffer.insert(&mut iter, &text[pos..start]);
}
let anchor = buffer.create_child_anchor(&mut iter);
view.add_child_at_anchor(&pill, &anchor);
pos = end;
}
if pos != text.len() {
buffer.insert(&mut iter, &text[pos..])
}
}
self.current_composer_state()
.set_related_to(Some(RelationInfo::Edit(info)));
imp.message_entry.grab_focus();
}
/// The relation to send with the current message.
fn send_relation(&self) -> Option<SendRelation> {
let related_event_item = self.related_event()?.item();
match self.related_event_type() {
RelatedEventType::None => None,
RelatedEventType::Reply => Some(SendRelation::Reply(
related_event_item.replied_to_info().ok()?,
)),
RelatedEventType::Edit => {
Some(SendRelation::Edit(related_event_item.edit_info().ok()?))
}
}
}
/// Get an iterator over chunks of the message entry's text between the
/// given start and end, split by mentions.
fn split_buffer_mentions(&self, start: gtk::TextIter, end: gtk::TextIter) -> SplitMentions {
SplitMentions { iter: start, end }
}
/// Send the text message that is currently in the message entry.
async fn send_text_message(&self) {
let imp = self.imp();
@ -572,7 +603,8 @@ impl MessageToolbar {
return;
};
let buffer = imp.message_entry.buffer();
let composer_state = self.current_composer_state();
let buffer = composer_state.buffer();
let (start_iter, end_iter) = buffer.bounds();
let body_len = buffer.text(&start_iter, &end_iter, true).len();
@ -583,7 +615,7 @@ impl MessageToolbar {
let mut formatted_body = String::with_capacity(body_len);
let mut mentions = Mentions::new();
let mut split_mentions = self.split_buffer_mentions(start_iter, end_iter);
let mut split_mentions = SplitMentions::new(start_iter, end_iter);
while let Some(chunk) = split_mentions.next().await {
match chunk {
MentionChunk::Text(text) => {
@ -652,8 +684,8 @@ impl MessageToolbar {
let matrix_timeline = room.timeline().matrix_timeline();
// Send event depending on relation.
match self.send_relation() {
Some(SendRelation::Reply(replied_to_info)) => {
match composer_state.related_to() {
Some(RelationInfo::Reply(replied_to_info)) => {
let handle = spawn_tokio!(async move {
matrix_timeline
.send_reply(content, replied_to_info, ForwardThread::Yes)
@ -664,7 +696,7 @@ impl MessageToolbar {
toast!(self, gettext("Could not send reply"));
}
}
Some(SendRelation::Edit(edit_info)) => {
Some(RelationInfo::Edit(edit_info)) => {
let handle =
spawn_tokio!(async move { matrix_timeline.edit(content, edit_info).await });
if let Err(error) = handle.await.unwrap() {
@ -685,9 +717,8 @@ impl MessageToolbar {
}
}
// Clear the message entry.
buffer.set_text("");
self.clear_related_event();
// Clear the composer state.
composer_state.clear();
}
/// Open the emoji chooser in the message entry.
@ -1019,10 +1050,10 @@ impl MessageToolbar {
/// Scrolls to the corresponding event.
#[template_callback]
fn handle_related_event_click(&self) {
if let Some(event) = &*self.imp().related_event.borrow() {
if let Some(related_to) = self.current_composer_state().related_to() {
self.activate_action(
"room-history.scroll-to-event",
Some(&event.key().to_variant()),
Some(&related_to.key().to_variant()),
)
.unwrap();
}
@ -1054,7 +1085,7 @@ impl MessageToolbar {
let body_len = buffer.text(&start, &end, true).len();
let mut body = String::with_capacity(body_len);
let mut split_mentions = self.split_buffer_mentions(start, end);
let mut split_mentions = SplitMentions::new(start, end);
while let Some(chunk) = split_mentions.next().await {
match chunk {
MentionChunk::Text(text) => {
@ -1122,6 +1153,13 @@ struct SplitMentions {
end: gtk::TextIter,
}
impl SplitMentions {
/// Construct a `SplitMention` to iterate between the given start and end.
fn new(start: gtk::TextIter, end: gtk::TextIter) -> Self {
Self { iter: start, end }
}
}
impl SplitMentions {
async fn next(&mut self) -> Option<MentionChunk> {
if self.iter == self.end {
@ -1197,13 +1235,3 @@ impl SplitMentions {
}
}
}
/// The possible relations to send with a message.
#[derive(Debug)]
enum SendRelation {
/// Send a reply with the given replied to info.
Reply(RepliedToInfo),
/// Send an edit with the given edit info.
Edit(EditInfo),
}

6
src/session/view/content/room_history/message_toolbar/mod.ui

@ -31,9 +31,9 @@
</style>
<property name="spacing">12</property>
<binding name="visible">
<closure type="gboolean" function="object_is_some">
<lookup name="related-event">MessageToolbar</lookup>
</closure>
<lookup name="has-relation">
<lookup name="current-composer-state">MessageToolbar</lookup>
</lookup>
</binding>
<child>
<object class="GtkBox">

26
src/utils/matrix.rs

@ -8,7 +8,7 @@ use matrix_sdk::{
encryption::{BackupDownloadStrategy, EncryptionSettings},
Client, ClientBuildError,
};
use matrix_sdk_ui::timeline::TimelineItemContent;
use matrix_sdk_ui::timeline::{Message, TimelineItemContent};
use ruma::{
events::{
room::{member::MembershipState, message::MessageType},
@ -775,8 +775,8 @@ pub enum MatrixIdUriParseError {
UnsupportedId(MatrixId),
}
/// Helper trait for [`TimelineItemContent`].
pub trait TimelineItemContentExt {
/// Helper trait for types possibly containing an `@room` mention.
pub trait AtMentionExt {
/// Whether this event might contain an `@room` mention.
///
/// This means that either it doesn't have intentional mentions, or it has
@ -784,17 +784,21 @@ pub trait TimelineItemContentExt {
fn can_contain_at_room(&self) -> bool;
}
impl TimelineItemContentExt for TimelineItemContent {
impl AtMentionExt for TimelineItemContent {
fn can_contain_at_room(&self) -> bool {
match self {
TimelineItemContent::Message(msg) => {
let Some(mentions) = msg.mentions() else {
return true;
};
mentions.room
}
TimelineItemContent::Message(msg) => msg.can_contain_at_room(),
_ => false,
}
}
}
impl AtMentionExt for Message {
fn can_contain_at_room(&self) -> bool {
let Some(mentions) = self.mentions() else {
return true;
};
mentions.room
}
}

Loading…
Cancel
Save